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:
@@ -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())");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user