package cn.iocoder.yudao.module.qcreport.engine.render;
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.provider.ValueSource;
|
|
import java.io.IOException;
|
import java.io.InputStream;
|
import java.nio.charset.StandardCharsets;
|
import java.util.List;
|
import java.util.Map;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
/**
|
* 画布安全性校验测试。
|
* <p>
|
* 两类断言各守一件事:
|
* <ul>
|
* <li><b>安全</b>:凡是渲染时会原样拼进产物的脏内容(标签名、属性名、整段 CSS)都要被拦下,
|
* 否则模板作者就能往报告里塞脚本或提前闭合 {@code <style>}。</li>
|
* <li><b>不误杀</b>:库里全部真实存量画布都必须通过({@code qcreport/canvas/*.json} 是从运行库导出的原样数据),
|
* 白名单一旦收得过紧,正常模板会突然保存不了。</li>
|
* </ul>
|
* <p>
|
* 用例里的画布一律写成<b>真实形状</b>({@code pages[0].frames[0].component} 是根 wrapper,脏节点挂在它的
|
* {@code components} 里)。若把 {@code components} 摆在 grapes 根上,校验器走不到它、断言会「因为什么都没检查」
|
* 而假通过——安全测试的假通过比不写更危险。
|
*/
|
class CanvasSafetyTest {
|
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
private static final String CANVAS_DIR = "qcreport/canvas/";
|
|
/** 一份最小正常包装:根 wrapper + 一个 h2 + 一张两行的表,tags 全在白名单内 */
|
private static final String VALID_WRAPPER = """
|
{
|
"type": "wrapper",
|
"components": [
|
{ "type": "text", "tagName": "h2", "components": [ { "type": "textnode", "content": "来料检验报告" } ] },
|
{
|
"type": "table",
|
"components": [ {
|
"type": "row",
|
"components": [
|
{ "type": "cell", "tagName": "th", "attributes": { "colspan": "2" }, "components": [] },
|
{ "type": "cell", "components": [ { "type": "textnode", "content": "合格" } ] }
|
]
|
} ]
|
}
|
]
|
}""";
|
|
@Test
|
@DisplayName("正常画布不放行任何问题")
|
void acceptsNormalCanvas() {
|
assertEquals(List.of(), CanvasSafety.validate(canvas(VALID_WRAPPER)));
|
|
// 样式也走一遍正常路径:规则数组(选择器 + 属性 + 媒体查询)是设计器保存后的标准形态,不能被误判
|
assertEquals(List.of(), CanvasSafety.validate(grapes("""
|
{
|
"pages": [ { "frames": [ { "component": { "type": "wrapper", "components": [] } } ] } ],
|
"styles": [ {
|
"selectors": [ "#ih35" ], "style": { "padding": "0" },
|
"mediaText": "(max-width: 794px)", "atRuleType": "media"
|
} ]
|
}""")));
|
}
|
|
@ParameterizedTest(name = "存量画布 {0} 必须通过")
|
@ValueSource(strings = {
|
"canvas-t1-v1.0.json", "canvas-t1-v1.1.json", "canvas-t5-v1.0.json",
|
"canvas-t3-v1.0.json", "canvas-t5-v1.1.json", "canvas-t5-v1.2.json"})
|
@DisplayName("运行库导出的真实存量画布全部通过(白名单不能误杀正常模板)")
|
void acceptsRealStoredCanvases(String fileName) throws IOException {
|
Map<String, Object> grapes = grapesFromFixture(fileName);
|
|
assertEquals(List.of(), CanvasSafety.validate(grapes),
|
fileName + " 是运行库里真实的模板画布,被新校验拦下说明白名单或规则收得过紧");
|
}
|
|
@Test
|
@DisplayName("可执行/嵌入式标签被拒,且提示里点名是哪个标签")
|
void rejectsDangerousTag() {
|
List<String> problems = CanvasSafety.validate(canvas("""
|
{ "type": "wrapper", "components": [
|
{ "type": "text", "tagName": "script", "components": [] } ] }"""));
|
|
assertEquals(1, problems.size());
|
assertTrue(problems.get(0).contains("script"), "提示要点明是哪个标签,否则用户不知道去删什么");
|
assertTrue(problems.get(0).contains("第 1 个组件"), "提示要给出位置:" + problems.get(0));
|
}
|
|
@Test
|
@DisplayName("tagName 里塞属性也拦得住(标签名是直接拼进产物的,不转义)")
|
void rejectsAttributeSmuggledInTagName() {
|
// 若标签名不过白名单,这一手会渲染成 <img src=x onerror=alert(1)> —— 事件属性绕过了属性名的检查
|
List<String> problems = CanvasSafety.validate(canvas("""
|
{ "type": "wrapper", "components": [
|
{ "type": "image", "tagName": "img src=x onerror=alert(1)", "components": [] } ] }"""));
|
|
assertFalse(problems.isEmpty(), "带空格和等号的「标签名」必须被拒,否则等于放行一个事件属性");
|
assertTrue(problems.get(0).contains("img src=x onerror=alert(1)"), problems.get(0));
|
}
|
|
@Test
|
@DisplayName("属性名含引号/空格被拒(属性名不转义,可顶出新属性)")
|
void rejectsIllegalAttributeName() {
|
List<String> problems = CanvasSafety.validate(canvas("""
|
{ "type": "wrapper", "components": [
|
{ "type": "text", "tagName": "p", "attributes": { "x\\" onload\\"alert(1)": "1" }, "components": [] } ] }"""));
|
|
assertEquals(1, problems.size());
|
assertTrue(problems.get(0).contains("不是合法的属性名"), problems.get(0));
|
}
|
|
@Test
|
@DisplayName("事件属性被拒")
|
void rejectsEventAttribute() {
|
List<String> problems = CanvasSafety.validate(canvas("""
|
{ "type": "wrapper", "components": [
|
{ "type": "image", "tagName": "img", "attributes": { "onerror": "alert(1)" }, "components": [] } ] }"""));
|
|
assertEquals(1, problems.size());
|
assertTrue(problems.get(0).contains("onerror") && problems.get(0).contains("事件属性"), problems.get(0));
|
}
|
|
@Test
|
@DisplayName("整段 CSS 字符串被拒(buildCss 会原样塞进 <style>,能提前闭合)")
|
void rejectsRawCssString() {
|
List<String> problems = CanvasSafety.validate(grapes("""
|
{
|
"pages": [ { "frames": [ { "component": { "type": "wrapper", "components": [] } } ] } ],
|
"styles": "</style><script>alert(1)</script>"
|
}"""));
|
|
assertEquals(1, problems.size());
|
assertTrue(problems.get(0).contains("整段 CSS"), problems.get(0));
|
}
|
|
@Test
|
@DisplayName("样式规则里出现「<」被拒:选择器、属性值、媒体查询条件都算")
|
void rejectsAngleBracketInStyleFragments() {
|
List<String> bySelector = CanvasSafety.validate(grapes("""
|
{ "pages": [ { "frames": [ { "component": { "type": "wrapper", "components": [] } } ] } ],
|
"styles": [ { "selectors": [ "a</style><script>x</script>" ], "style": { "color": "red" } } ] }"""));
|
assertTrue(bySelector.stream().anyMatch(p -> p.contains("选择器")), bySelector.toString());
|
|
List<String> byValue = CanvasSafety.validate(grapes("""
|
{ "pages": [ { "frames": [ { "component": { "type": "wrapper", "components": [] } } ] } ],
|
"styles": [ { "selectors": [ "p" ], "style": { "content": "</style>" } } ] }"""));
|
assertTrue(byValue.stream().anyMatch(p -> p.contains("content")), byValue.toString());
|
|
List<String> byMedia = CanvasSafety.validate(grapes("""
|
{ "pages": [ { "frames": [ { "component": { "type": "wrapper", "components": [] } } ] } ],
|
"styles": [ { "selectors": [ "p" ], "mediaText": "print</style>x", "style": { "color": "red" } } ] }"""));
|
assertTrue(byMedia.stream().anyMatch(p -> p.contains("媒体查询")), byMedia.toString());
|
}
|
|
@Test
|
@DisplayName("textnode 上的 tagName 不参与校验:渲染器根本不读它,不该因此拦住保存")
|
void ignoresTagNameOfTextNode() {
|
assertEquals(List.of(), CanvasSafety.validate(canvas("""
|
{ "type": "wrapper", "components": [
|
{ "type": "textnode", "tagName": "script", "content": "纯文本" } ] }""")));
|
}
|
|
@Test
|
@DisplayName("只沿 components 走:GrapesJS 挂在节点上的 docEl / head 元数据不触发误判")
|
void ignoresNonRenderedMetadata() {
|
// 真实画布里 frame 的根组件上就挂着 docEl:{tagName:"html"} 与 head:{type:"head"},渲染器两个都不读
|
assertEquals(List.of(), CanvasSafety.validate(grapes("""
|
{ "pages": [ { "frames": [ { "component": {
|
"type": "wrapper",
|
"docEl": { "tagName": "html" },
|
"head": { "type": "head", "components": [ { "type": "text", "tagName": "style" } ] },
|
"components": [ { "type": "text", "tagName": "p", "components": [] } ] } } ] } ] }""")));
|
}
|
|
@Test
|
@DisplayName("问题条数有上限:脏内容很多时也不要吐一屏提示")
|
void capsProblemCount() {
|
StringBuilder components = new StringBuilder();
|
for (int i = 0; i < 20; i++) {
|
components.append(i == 0 ? "" : ",").append("{ \"type\": \"text\", \"tagName\": \"script\" }");
|
}
|
List<String> problems = CanvasSafety.validate(canvas(
|
"{ \"type\": \"wrapper\", \"components\": [ " + components + " ] }"));
|
|
assertEquals(8, problems.size(), "上限是 8 条,改完再存一次就能看到下一批");
|
}
|
|
@Test
|
@DisplayName("grapes 为空不报错(新建模板还没画东西)")
|
void toleratesNullGrapes() {
|
assertEquals(List.of(), CanvasSafety.validate(Map.of()));
|
assertEquals(List.of(), CanvasSafety.validate(Map.of("pages", List.of())));
|
}
|
|
/** 把用例写的根 wrapper 包成真实画布形状:grapes.pages[0].frames[0].component */
|
private static Map<String, Object> canvas(String wrapperJson) {
|
return grapes("{\"pages\":[{\"frames\":[{\"component\":" + wrapperJson + "}]}]}");
|
}
|
|
private static Map<String, Object> grapes(String grapesJson) {
|
try {
|
return MAPPER.readValue(grapesJson, new TypeReference<Map<String, Object>>() {
|
});
|
} catch (IOException e) {
|
throw new IllegalArgumentException("测试用例的 JSON 写错了:" + grapesJson, e);
|
}
|
}
|
|
private static String readCanvasFixture(String fileName) throws IOException {
|
try (InputStream in = CanvasSafetyTest.class.getClassLoader().getResourceAsStream(CANVAS_DIR + fileName)) {
|
if (in == null) {
|
throw new IOException("找不到画布夹具:" + CANVAS_DIR + fileName);
|
}
|
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
}
|
}
|
|
/** 夹具是完整 Schema,校验器只吃里面的 grapes */
|
private static Map<String, Object> grapesFromFixture(String fileName) throws IOException {
|
Map<String, Object> schema = grapes(readCanvasFixture(fileName));
|
Object grapes = schema.get("grapes");
|
assertTrue(grapes instanceof Map<?, ?>, fileName + " 里没有 grapes,夹具可能导错了");
|
return asMap(grapes);
|
}
|
|
@SuppressWarnings("unchecked")
|
private static Map<String, Object> asMap(Object value) {
|
return (Map<String, Object>) value;
|
}
|
|
}
|