feat(B3): custom_css 站点级自定义样式-服务端黑名单强校验+5主题head内联直出

- validateConfigJson 增 custom_css 分支:≤10240字符;黑名单忽略大小写拒 </style/<script/@import/expression(
- 快照/渲染链路(model customCss)与 themeVarsCss 同构注入;缓存失效复用既有 SiteContentChangedEvent
- 5 套主题 head.html 于 main.css 与微调变量之后 th:utext 直出(安全性完全依赖服务端先行)
- SiteServiceIT+6 例(含 expression 红线项与 10240 边界);PortalSsrIT+3 例(渲染/head内/顺序/缓存失效)
- mvn -o test 258 例全绿(B3 状态,不含 B4 红测)
This commit is contained in:
chenxing
2026-09-07 20:14:23 +08:00
parent b4822a7f38
commit 19e414ee81
11 changed files with 190 additions and 0 deletions
@@ -95,6 +95,7 @@ public class PortalController {
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", categoryId);
@@ -142,6 +143,7 @@ public class PortalController {
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", detail.getCategoryId());
@@ -179,6 +181,7 @@ public class PortalController {
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("q", q);
@@ -41,6 +41,7 @@ public class PortalPageRenderer {
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url);
@@ -57,6 +57,9 @@ public class PortalSiteSnapshot {
/** 服务端预构建的 :root{...} CSS 变量覆盖串(无有效参数时为空串,方案 §5.3) */
private String themeVarsCss = "";
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
private String customCss = "";
public static PortalSiteSnapshot of(CmsSite site) {
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
snapshot.setId(site.getId());
@@ -70,6 +73,7 @@ public class PortalSiteSnapshot {
snapshot.setConfig(config);
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
return snapshot;
}
@@ -94,6 +98,7 @@ public class PortalSiteSnapshot {
copy.setColleges(colleges);
copy.setThemeParams(themeParams);
copy.setThemeVarsCss(themeVarsCss);
copy.setCustomCss(customCss);
return copy;
}
}
@@ -59,6 +59,12 @@ public class SiteService {
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
private static final List<String> CUSTOM_CSS_BLACKLIST =
java.util.Collections.unmodifiableList(java.util.Arrays.asList(
"</style", "<script", "@import", "expression("));
/** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */
public List<SiteVo> listFor(BoaUser user) {
List<CmsSite> sites = authProperties.isSuperAdmin(user)
@@ -290,6 +296,11 @@ public class SiteService {
if (!StringUtils.hasText(text)) {
return;
}
// custom_css 独立校验(B3):上限 10KB + 黑名单,不走通用 500 字上限
if ("custom_css".equals(key)) {
validateCustomCss(text);
return;
}
if (text.length() > CONFIG_VALUE_MAX_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)");
}
@@ -318,4 +329,23 @@ public class SiteService {
}
});
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
* {@code expression(}(IE 表达式)。门户 5 套主题 head 以 th:utext 直出,安全性完全依赖本方法先行。
*/
private void validateCustomCss(String css) {
if (css.length() > CUSTOM_CSS_MAX_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR,
"custom_css 过长(" + css.length() + " 字符,上限 " + CUSTOM_CSS_MAX_LEN + ")");
}
String lower = css.toLowerCase(java.util.Locale.ROOT);
for (String banned : CUSTOM_CSS_BLACKLIST) {
if (lower.contains(banned)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"custom_css 含禁用内容:" + banned + "(不允许 </style、<script、@import、expression())");
}
}
}
}
@@ -15,6 +15,8 @@
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
@@ -15,6 +15,8 @@
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
@@ -15,6 +15,8 @@
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
@@ -13,6 +13,8 @@
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
@@ -15,6 +15,8 @@
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
@@ -3,6 +3,7 @@ package com.yunhai.cms.portal;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -360,4 +361,60 @@ class PortalSsrIT extends CmsIntegrationTestBase {
.contains("sso."));
mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk());
}
// ===== B3 custom_css 站点级自定义样式(head 内联输出)=====
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.cms.service.impl.SiteService siteService;
private void setSiteCss(String cssJson) {
siteRepository.findById(siteId).ifPresent(s -> {
s.setConfigJson(cssJson);
siteRepository.saveAndFlush(s);
});
}
@Test
@DisplayName("B3: 配置 custom_css 后 SSR 输出内联 style(head 内、皮肤 main.css 之后)")
void customCss_renderedAsInlineStyle() throws Exception {
setSiteCss("{\"custom_css\":\".site-header{padding:16px 0}\"}");
MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk())
.andReturn();
String html = body(result);
assertThat(html).contains(".site-header{padding:16px 0}");
assertThat(html).contains("<style>.site-header{padding:16px 0}</style>");
assertThat(html.indexOf(".site-header{padding:16px 0}")).as("内联输出须位于 head 内")
.isLessThan(html.indexOf("</head>"));
assertThat(html.indexOf("css/main.css")).as("覆盖优先级:位于皮肤 main.css 之后")
.isLessThan(html.indexOf(".site-header{padding:16px 0}"));
}
@Test
@DisplayName("B3: 未配置 custom_css 不输出 style 块(其他站点不受影响)")
void customCss_absent_whenNotConfigured() throws Exception {
MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk())
.andReturn();
assertThat(body(result)).doesNotContain("<style");
}
@Test
@DisplayName("B3: 改 custom_css 后快照缓存随保存事件失效,门户输出新值")
void customCss_changeInvalidatesCache() throws Exception {
setSiteCss("{\"custom_css\":\".mark-a{color:red}\"}");
assertThat(body(mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()).andReturn()))
.contains(".mark-a{color:red}");
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setId(siteId);
req.setCode("demo1");
req.setSchId(1);
req.setSchName("学校-1");
req.setName("云海第一中学门户");
req.setConfigJson("{\"custom_css\":\".mark-b{color:blue}\"}");
siteService.save(TestUsers.superAdmin(), req);
String html = body(mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()).andReturn());
assertThat(html).as("保存事件驱逐 cms:site 后输出新样式").contains(".mark-b{color:blue}");
assertThat(html).doesNotContain(".mark-a{color:red}");
}
}
@@ -720,4 +720,88 @@ class SiteServiceIT extends CmsIntegrationTestBase {
assertThat(attachmentRepository.findById(img2.getId())
.orElseThrow(IllegalStateException::new).getRefType()).as("清空后引用解除").isNull();
}
// ===== B3 custom_css 站点级自定义样式(服务端黑名单强校验)=====
private SiteSaveRequest cssReq(String code, String css) {
SiteSaveRequest req = baseConfigReq(code);
req.setConfigJson("{\"custom_css\":\"" + css + "\"}");
return req;
}
@Test
@DisplayName("B3: 合法 custom_css 保存成功(普通 CSS 规则)")
void save_customCss_valid_passes() {
String css = ".site-header{padding:16px 0}";
siteService.save(TestUsers.superAdmin(), cssReq("css1", css));
assertThat(siteService.configValue(siteService.getByCode("css1"), "custom_css"))
.isEqualTo(css);
}
@Test
@DisplayName("B3: custom_css 超过 10KB 被拒(上限 10240 字符)")
void save_customCss_overLimit_rejected() {
StringBuilder big = new StringBuilder();
for (int i = 0; i < 10241; i++) {
big.append('a');
}
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), cssReq("css2", big.toString())))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
// 恰好 10240 放行(边界)
StringBuilder edge = new StringBuilder();
for (int i = 0; i < 10240; i++) {
edge.append('a');
}
assertThat(siteService.save(TestUsers.superAdmin(), cssReq("css3", edge.toString()))).isNotNull();
}
@Test
@DisplayName("B3: custom_css 含 </style> 被拒(忽略大小写,防逃逸内联 style 块)")
void save_customCss_containsStyleClose_rejected() {
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css4", ".a{color:red}</style><b>")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css5", ".a{color:red}</STYLE>")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
}
@Test
@DisplayName("B3: custom_css 含 <script 被拒(忽略大小写)")
void save_customCss_containsScript_rejected() {
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css6", "</style><script>alert(1)</script>")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css7", ".a{color:red}<SCRIPT src=x>")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
}
@Test
@DisplayName("B3: custom_css 含 @import 被拒(防外链样式注入)")
void save_customCss_containsAtImport_rejected() {
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css8", "@import url(http://evil.com/x.css);")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css9", ".a{color:red}\\n@IMPORT 'x.css'")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
}
@Test
@DisplayName("B3(补充): custom_css 含 expression( 被拒(IE 表达式向量,红线四项之一)")
void save_customCss_containsExpression_rejected() {
assertThatThrownBy(() -> siteService.save(
TestUsers.superAdmin(), cssReq("css10", "body{width:EXPRESSION(alert(1))}")))
.isInstanceOf(BizException.class)
.hasMessageContaining("custom_css");
}
}