7 小时以前 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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
package cn.iocoder.yudao.module.qcreport.engine.render;
 
import cn.hutool.core.util.StrUtil;
 
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
 
/**
 * 画布安全性校验:模板保存时把「渲染时会原样拼进 HTML 的内容」先卡一遍。
 * <p>
 * 报告产物是拼字符串拼出来的({@link HtmlRenderer}),模板画布又是用户可编辑数据,
 * 所以画布里凡是<b>未经转义就进入产物</b>的内容都是注入面。逐条列明它们,改动渲染或本类时两边一起看:
 * <ul>
 *   <li>{@code resolveTag} —— 节点的 {@code tagName} 直接拼成 {@code <tag>},本类按<b>白名单</b>卡。</li>
 *   <li>{@code renderAttributes} —— 只对属性<b>值</b>做转义,属性<b>名</b>是直接拼上去的,
 *       一个含引号的属性名就能把后面的内容顶成新属性,本类按字符集卡。</li>
 *   <li>{@code buildCss} —— {@code styles} 若是字符串会被<b>原样返回</b>(整个 {@code <style>} 的内容),
 *       而 {@code <style>} 块里出现 {@code </style} 就能提前闭合、把后面变成真 HTML,
 *       本类要求 {@code styles} 必须是规则数组,并禁止选择器/属性名/属性值/mediaText 里出现 {@code <}。</li>
 * </ul>
 * <p>
 * <b>为什么在保存时卡而不是渲染时卡</b>:渲染链路上的 {@link HtmlRenderer} 与前端
 * {@code engine/render.ts} 必须逐字一致,改它就要重算对拍产物、并让存量报告的 {@code regenerate}
 * 不再逐字复现;而「入站内容是否可信」本来就是写入侧的问题,在数据进库前拦掉,渲染侧可以继续
 * 保持与前端完全对称。代价是<b>对校验上线前就已入库的脏数据没有兜底</b>——存量版本需要另行核查。
 * <p>
 * <b>为什么走的是白名单而不是过滤危险标签</b>:黑名单永远漏,而报告排版用得到的标签是有限的、
 * 可枚举的。正常模板不会因为这条白名单被拒(已对库里全部存量版本实测过)。
 */
public final class CanvasSafety {
 
    /**
     * 允许出现在画布里的标签。
     * <p>
     * 只收「排版与文本」类标签:组件注册表能产出的({@code div/p/span/hr/img/h1~h6} 与表格一族)
     * 加上 GrapesJS 基础组件与常见行内语义标签。刻意排除四类——
     * 可执行({@code script})、可注入样式({@code style})、可嵌入外部内容({@code iframe/object/embed})、
     * 可提交数据({@code form} 及其控件),以及 {@code svg/math} 这类自带脚本能力的命名空间标签。
     * <p>
     * GrapesJS 会在组件上挂 {@code docEl: {tagName: "html"}}、{@code head: {type: "head"}} 这类
     * <b>不会渲染</b>的元数据,所以本类只沿 {@code components} 走(与 {@code RenderNode.children()} 一致),
     * 白名单里也就不需要 {@code html}/{@code head}。
     */
    private static final Set<String> ALLOWED_TAGS = Set.of(
            // 区块与容器
            "div", "span", "section", "article", "header", "footer", "main", "aside", "nav",
            "figure", "figcaption", "blockquote", "pre", "address", "center",
            // 文本与标题
            "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
            "b", "i", "u", "s", "strong", "em", "small", "big", "sub", "sup", "mark",
            "code", "kbd", "samp", "var", "tt", "strike", "font", "wbr", "bdi", "bdo", "ruby", "rt", "rp",
            "abbr", "cite", "q", "time", "del", "ins",
            // 列表
            "ul", "ol", "li", "dl", "dt", "dd",
            // 表格
            "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "col", "colgroup",
            // 图片与链接
            "img", "a", "label");
 
    /** 合法的属性名:字母/下划线/冒号开头,其后字母数字与 {@code - _ : .}。含引号或空格的属性名一律拒绝 */
    private static final Pattern ATTRIBUTE_NAME = Pattern.compile("^[A-Za-z_:][-A-Za-z0-9_:.]*$");
 
    /** 最多报几条:一条报错信息里堆几十条没人看得下去,改完再存一次就能看到下一批 */
    private static final int MAX_PROBLEMS = 8;
 
    /** 路径展示的最大层数,再深就折叠成 …,避免一条提示里出现一长串「第 N 个组件」 */
    private static final int MAX_PATH_DEPTH = 6;
 
    private static final String UNSAFE_LT = "<";
 
    private CanvasSafety() {
    }
 
    /**
     * 校验一份画布。
     *
     * @param grapes GradesJS 项目数据(模板 Schema 的 {@code grapes} 字段),可为 null
     * @return 问题清单,空列表代表通过。文案已可直接拼给用户看
     */
    public static List<String> validate(Map<String, Object> grapes) {
        List<String> problems = new ArrayList<>();
        if (grapes == null) {
            return problems;
        }
        validateComponentTree(grapes, problems);
        validateStyles(grapes.get("styles"), problems);
        return problems;
    }
 
    /* ------------------------------ 组件树 ------------------------------ */
 
    private static void validateComponentTree(Map<String, Object> grapes, List<String> problems) {
        for (Map<String, Object> frameComponent : frameComponents(grapes)) {
            walk(frameComponent, "根", 0, problems);
        }
    }
 
    /** 取所有页所有 frame 的根组件。渲染器只读 {@code pages[0].frames[0]},这里全查一遍:多出来的部分也不能藏脏东西 */
    private static List<Map<String, Object>> frameComponents(Map<String, Object> grapes) {
        List<Map<String, Object>> result = new ArrayList<>();
        collectFrames(grapes.get("pages"), result);
        return result;
    }
 
    @SuppressWarnings("unchecked")
    private static void collectFrames(Object pages, List<Map<String, Object>> result) {
        if (!(pages instanceof List<?> pageList)) {
            return;
        }
        for (Object rawPage : pageList) {
            if (!(rawPage instanceof Map<?, ?> page)) {
                continue;
            }
            Object frames = ((Map<String, Object>) page).get("frames");
            if (!(frames instanceof List<?> frameList)) {
                continue;
            }
            for (Object rawFrame : frameList) {
                if (rawFrame instanceof Map<?, ?> frame
                        && ((Map<String, Object>) frame).get("component") instanceof Map<?, ?> component) {
                    result.add((Map<String, Object>) component);
                }
            }
        }
    }
 
    @SuppressWarnings("unchecked")
    private static void walk(Map<String, Object> node, String path, int depth, List<String> problems) {
        if (problems.size() >= MAX_PROBLEMS) {
            return;
        }
        validateNodeTag(node, path, problems);
        validateNodeAttributes(node, path, problems);
 
        if (!(node.get("components") instanceof List<?> children)) {
            return;
        }
        int index = 0;
        for (Object raw : children) {
            if (!(raw instanceof Map<?, ?> child)) {
                continue;
            }
            index++;
            walk((Map<String, Object>) child, path + " > 第 " + index + " 个组件", depth + 1, problems);
            if (problems.size() >= MAX_PROBLEMS) {
                return;
            }
        }
    }
 
    private static void validateNodeTag(Map<String, Object> node, String path, List<String> problems) {
        Object raw = node.get("tagName");
        String tagName = raw == null ? null : String.valueOf(raw).trim();
        // textnode 不产生标签(HtmlRenderer 对它单独处理),它身上的 tagName 渲染时被忽略,不必拦
        if (StrUtil.isBlank(tagName) || "textnode".equals(node.get("type"))) {
            return;
        }
        if (ALLOWED_TAGS.contains(tagName.toLowerCase(Locale.ROOT))) {
            return;
        }
        problems.add(StrUtil.format(
                "{} 的标签是「{}」,报告不支持该标签。可用的只有常规排版标签"
                        + "(div/p/span/h1~h6/img/hr 与表格、列表、行内文本标签),"
                        + "脚本、样式、内嵌页面、表单类标签一律不允许",
                displayPath(path), tagName));
    }
 
    @SuppressWarnings("unchecked")
    private static void validateNodeAttributes(Map<String, Object> node, String path, List<String> problems) {
        if (!(node.get("attributes") instanceof Map<?, ?> attributes)) {
            return;
        }
        for (Object key : ((Map<String, Object>) attributes).keySet()) {
            String name = String.valueOf(key);
            if (!ATTRIBUTE_NAME.matcher(name).matches()) {
                problems.add(StrUtil.format(
                        "{} 的属性名「{}」不是合法的属性名(只能由字母、数字、- _ : . 组成,且不能以数字开头),"
                                + "该属性会让报告产物结构错乱",
                        displayPath(path), name));
                continue;
            }
            if (name.toLowerCase(Locale.ROOT).startsWith("on")) {
                problems.add(StrUtil.format(
                        "{} 的属性「{}」是事件属性,报告不支持",
                        displayPath(path), name));
            }
        }
    }
 
    /* ------------------------------ 样式 ------------------------------ */
 
    @SuppressWarnings("unchecked")
    private static void validateStyles(Object styles, List<String> problems) {
        if (styles == null) {
            return;
        }
        if (styles instanceof String text) {
            if (StrUtil.isNotBlank(text)) {
                // buildCss 会把它原样塞进 <style>,等于让模板自带一整段不受控的 CSS(还能提前闭合 <style>)
                problems.add("画布的 styles 是一段 CSS 文本,报告不接受整段 CSS,"
                        + "请改用设计器的样式面板逐条设置样式(保存后是样式规则数组)");
            }
            return;
        }
        if (!(styles instanceof List<?> rules)) {
            problems.add("画布的 styles 既不是样式规则数组也不是 CSS 文本,无法识别,请重新保存模板");
            return;
        }
        for (int i = 0; i < rules.size() && problems.size() < MAX_PROBLEMS; i++) {
            if (!(rules.get(i) instanceof Map<?, ?> rule)) {
                continue;
            }
            Map<String, Object> ruleMap = (Map<String, Object>) rule;
            validateStyleFragments(ruleMap.get("selectors"), "样式规则 " + (i + 1) + " 的选择器", problems);
            validateStyleFragments(ruleMap.get("mediaText"), "样式规则 " + (i + 1) + " 的媒体查询条件", problems);
            Object style = ruleMap.get("style");
            if (style instanceof Map<?, ?> styleMap) {
                for (Map.Entry<?, ?> entry : ((Map<String, Object>) styleMap).entrySet()) {
                    String where = StrUtil.format("样式规则 {} 的属性「{}」",
                            i + 1, String.valueOf(entry.getKey()));
                    if (String.valueOf(entry.getKey()).contains(UNSAFE_LT)) {
                        problems.add(where + "名字里含有「<」,报告不支持");
                        continue;
                    }
                    validateStyleFragments(entry.getValue(), where, problems);
                }
            }
        }
    }
 
    /** 选择器/媒体查询/样式值都进 {@code <style>},出现 {@code <} 就可能闭合 style 块 */
    private static void validateStyleFragments(Object raw, String where, List<String> problems) {
        if (raw instanceof List<?> list) {
            for (Object item : list) {
                validateStyleFragments(item, where, problems);
            }
            return;
        }
        if (raw == null || problems.size() >= MAX_PROBLEMS) {
            return;
        }
        String text = String.valueOf(raw);
        if (text.contains(UNSAFE_LT)) {
            problems.add(StrUtil.format("{}「{}」里含有「<」,报告不支持,请在设计器里改掉",
                    where, StrUtil.maxLength(text, 60)));
        }
    }
 
    /* ------------------------------ 展示 ------------------------------ */
 
    /** 路径太深就折叠中段,报错信息里出现一长串「第 N 个组件」对定位没有帮助 */
    private static String displayPath(String path) {
        String[] segments = path.split(" > ");
        if (segments.length <= MAX_PATH_DEPTH) {
            return "画布「" + path + "」";
        }
        List<String> shown = new ArrayList<>(List.of(segments[0], segments[1], "…"));
        for (int i = segments.length - 3; i < segments.length; i++) {
            shown.add(segments[i]);
        }
        return "画布「" + String.join(" > ", shown) + "」";
    }
 
}