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