5 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
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;
    }
 
}