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())");
}
}
}
}