zh CN 教程 安全UGC - chiba233/yumeDSL GitHub Wiki

教程:安全 UGC 聊天

← Home


问题描述

你有一个聊天或评论系统。用户输入消息。你希望允许简单的格式化 -- 粗体、斜体、链接 -- 但要防止滥用:

  • 不允许 block 或 raw 标签(可能破坏布局或注入原始内容)
  • 未知标签应显示为纯文本,而非抛出错误
  • 格式错误的标记绝不能导致解析器崩溃
  • URL 必须经过清理(禁止 javascript:data: 协议)
  • 为审核工具提供错误报告

本教程将逐步引导你使用 yume-dsl-rich-text 构建一个完整的、可用于生产环境的 UGC 聊天消息处理管道。每个步骤都包含可运行的代码和攻击场景测试,让你能够自行验证安全特性。


第 1 步:定义白名单

第一道防线是限制解析器接受 哪些标签哪些标签形式。聊天系统通常只需要 inline 格式化 -- 不需要多行代码块、不需要原始内容注入、不需要块级布局容器。

创建解析器

import {
    createParser,
    createSimpleInlineHandlers,
    createPipeHandlers,
    type PipeArgs,
    type TokenDraft,
    type TextToken,
    type ParseError,
} from "yume-dsl-rich-text";

const chatParser = createParser({
    handlers: {
        // 简单 inline 格式化 -- bold、italic、underline、strike、code
        ...createSimpleInlineHandlers(["bold", "italic", "underline", "strike", "code"]),

        // 带 URL 清理的链接处理器(见第 4 步)
        ...createPipeHandlers({
            link: {
                inline(args, ctx) {
                    const rawUrl = args.text(0);
                    const url = sanitizeUrl(rawUrl);
                    return {
                        type: "link",
                        url,
                        value: args.materializedTailTokens(1),
                    };
                },
            },
        }),
    },

    // 关键安全措施:仅允许 inline 形式
    allowForms: ["inline"],
});

为什么 allowForms: ["inline"] 如此重要

allowForms 选项是 UGC 最重要的安全设置。它全局限制解析器 -- 不是按标签限制,而是对整个解析操作生效。以下是它允许和不允许的内容:

允许(inline 形式):

输入 结果
$$bold(hello)$$ 解析为 bold token
$$italic(world)$$ 解析为 italic token
$$link(https://example.com | click)$$ 解析为 link token
$$bold($$italic(nested)$$)$$ 解析为包含 italic 的 bold

被阻止(raw 和 block 形式):

输入 结果
$$code(js)%\nalert('xss')\n%end$$ 整段标记变为纯文本
$$info(title)*\n<script>...</script>\n*end$$ 整段标记变为纯文本
$$unknown()*\nmalicious content\n*end$$ 整段标记变为纯文本

allowForms 不包含 "raw""block" 时,解析器将这些形式视为处理器不支持的形式。原始的 $$code(js)%\nalert('xss')\n%end$$ 语法根本不会被解析为标签 -- 它作为纯文本字符流入输出。不会抛出错误,不需要特殊处理。用户只会看到原始标记文本。

这适用于所有标签,包括未注册的标签。即使有人构造 $$exploit()*\n...\n*end$$,block 形式已被全局禁用,因此解析器永远不会进入 block 解析模式。

对比:有无 allowForms

不使用 allowForms(对 UGC 不安全):

const unsafeParser = createParser({
    handlers: {
        ...createSimpleInlineHandlers(["bold"]),
        ...createSimpleRawHandlers(["code"]),
    },
    // 没有 allowForms -- 默认启用所有形式
});

unsafeParser.parse("$$code(js)%\nalert(document.cookie)\n%end$$");
// 结果:[{ type: "code", arg: "js", value: "alert(document.cookie)", id: "rt-0" }]
// 原始内容被逐字捕获 -- 如果渲染器不进行转义,这就是 XSS 攻击向量。

使用 allowForms: ["inline"](安全):

chatParser.parse("$$code(js)%\nalert(document.cookie)\n%end$$");
// 结果:[{ type: "text", value: "$$code(js)%\nalert(document.cookie)\n%end$$", id: "rt-0" }]
// 整个输入都是纯文本。没有任何标签被识别。

第 2 步:测试优雅降级

解析器的设计原则是对格式错误的输入绝不抛出异常。每个语法错误、每个未知标签、每次嵌套滥用都会降级为纯文本。这对 UGC 至关重要:你无法预测用户会输入什么,而崩溃意味着拒绝服务。

以下是关键的降级场景及其准确输出:

2.1 未注册的标签 -- 内容变为纯文本

chatParser.parse("$$unknown(hello)$$");
// 结果:
// [{ type: "text", value: "hello", id: "rt-0" }]

标签 unknown 不在 handlers 映射中。解析器识别了语法但没有对应的处理器,因此内容 "hello" 被展开为纯文本。$$unknown()$$ 分隔符被剥离,只有内部内容保留。

2.2 未关闭的标签 -- 整个字符串变为纯文本

const errors: ParseError[] = [];
chatParser.parse("$$bold(unclosed", {
    onError: (e) => errors.push(e),
});
// 结果:
// [{ type: "text", value: "$$bold(unclosed", id: "rt-0" }]
//
// errors[0]:
// {
//   code: "INLINE_NOT_CLOSED",
//   message: "(L1:C1) Inline tag not closed:  >>>$$bold(<<< unclosed",
//   line: 1,
//   column: 1,
//   snippet: " >>>$$bold(<<< unclosed"
// }

开始的 $$bold( 从未以 )$$ 关闭。解析器报告 INLINE_NOT_CLOSED 并通过将整个字符串视为纯文本来恢复。不会崩溃,不会产生部分 token。

2.3 被 allowForms 阻止的 raw 形式 -- 纯文本

chatParser.parse("$$code(js)%\nalert(1)\n%end$$");
// 结果:
// [{ type: "text", value: "$$code(js)%\nalert(1)\n%end$$", id: "rt-0" }]

即使语法是完全有效的 raw 形式 DSL,allowForms: ["inline"] 设置意味着 raw 形式被全局禁用。解析器甚至不会尝试将其作为 raw 标签解析。

2.4 深层嵌套 -- DEPTH_LIMIT 错误

const deepInput = "$$bold(".repeat(100) + "hello" + ")$$".repeat(100);
const errors: ParseError[] = [];
chatParser.parse(deepInput, {
    onError: (e) => errors.push(e),
});
// 在深度 50(默认 depthLimit)时,解析器停止递归。
// 超出限制的标签降级为纯文本。
// errors 将包含至少一个 code 为 "DEPTH_LIMIT" 的条目

默认 depthLimit 为 50。对于聊天系统,你可能需要降低它:

const chatParser = createParser({
    handlers: { /* ... */ },
    allowForms: ["inline"],
    depthLimit: 10,  // 聊天消息很少需要超过几层嵌套
});

2.5 正常使用 -- 按预期工作

chatParser.parse("$$bold(hello $$italic(world)$$)$$");
// 结果:
// [
//   {
//     type: "bold",
//     value: [
//       { type: "text", value: "hello ", id: "rt-0" },
//       {
//         type: "italic",
//         value: [{ type: "text", value: "world", id: "rt-1" }],
//         id: "rt-2",
//       },
//     ],
//     id: "rt-3",
//   },
// ]

嵌套的 inline 标签正确解析。bold token 同时包含一个文本节点和一个 italic 子 token。

2.6 混合有效和无效内容 -- 部分恢复

chatParser.parse("$$bold(hello)$$ $$unknown(oops)$$ $$italic(world)$$");
// 结果:
// [
//   { type: "bold", value: [{ type: "text", value: "hello", ... }], ... },
//   { type: "text", value: " ", ... },
//   { type: "text", value: "oops", ... },
//   { type: "text", value: " ", ... },
//   { type: "italic", value: [{ type: "text", value: "world", ... }], ... },
// ]

有效的 bolditalic 标签正常解析。未注册的 unknown 标签降级为纯文本 "oops"。周围的内容不受影响。


第 3 步:添加错误报告

解析器的 onError 回调是你观察格式错误输入的窗口。对于聊天系统,错误数据对审核非常有价值 -- 一条充满解析错误的消息很可能是垃圾信息或攻击尝试。

收集错误

function parseMessage(input: string) {
    const errors: ParseError[] = [];
    const tokens = chatParser.parse(input, {
        onError: (e) => errors.push(e),
    });
    return { tokens, errors };
}

ParseError 接口

每个错误包含:

interface ParseError {
    code: ErrorCode;
    message: string;
    line: number;
    column: number;
    snippet: string;
}
字段 描述
code 机器可读的错误类型(ErrorCode 联合类型)
message 人类可读的消息,带有 (L{line}:C{column}) 前缀和 >>>...<<< 片段标记
line 错误开始位置的行号(从 1 开始)
column 错误开始位置的列号(从 1 开始)
snippet 错误周围的上下文,用 >>> <<< 标记指示问题范围

与 UGC 聊天相关的错误码

使用 allowForms: ["inline"] 时,你主要会遇到以下错误码:

错误码 含义 触发示例
INLINE_NOT_CLOSED inline 标签已打开但从未关闭 $$bold(unclosed
SHORTHAND_NOT_CLOSED 隐式 inline 简写开了但没关(1.3 起) 启用 implicitInlineShorthand 时的 bold(unclosed
UNEXPECTED_CLOSE 无匹配打开标签的关闭标记 文本中间的独立 )$$
DEPTH_LIMIT 嵌套超过 depthLimit 超过限制的 $$a($$b($$c($$d(...

不会看到 BLOCK_NOT_CLOSEDRAW_NOT_CLOSED 或它们的 malformed 变体,因为 block 和 raw 形式已被 allowForms: ["inline"] 全局禁用。

利用错误进行审核

interface ModerationResult {
    tokens: TextToken[];
    flagged: boolean;
    reason?: string;
}

function moderateMessage(input: string): ModerationResult {
    const { tokens, errors } = parseMessage(input);

    // 标记解析错误过多的消息
    if (errors.length > 5) {
        return {
            tokens,
            flagged: true,
            reason: `解析错误过多 (${errors.length}):可能存在标记滥用`,
        };
    }

    // 标记深度限制命中的消息 -- 可能是嵌套攻击
    const depthErrors = errors.filter((e) => e.code === "DEPTH_LIMIT");
    if (depthErrors.length > 0) {
        return {
            tokens,
            flagged: true,
            reason: `深度限制被触发 ${depthErrors.length} 次:可能是嵌套攻击`,
        };
    }

    return { tokens, flagged: false };
}

记录错误用于监控

function logParseErrors(userId: string, messageId: string, errors: ParseError[]) {
    for (const err of errors) {
        console.warn(
            `[UGC Parse Error] user=${userId} msg=${messageId} ` +
            `code=${err.code} L${err.line}:C${err.column} ${err.snippet}`
        );
    }
}

这为你提供了一个结构化数据流,可以输入到监控系统中。来自单个用户的 DEPTH_LIMIT 错误突然激增是滥用的强烈信号。


第 4 步:链接处理器中的 URL 清理

解析器不会验证 URL -- 这是处理器的职责。对于 UGC,你必须清理 URL 以防止 javascript:data: 和其他危险协议。

sanitizeUrl 函数

function sanitizeUrl(raw: string): string | undefined {
    if (!raw) return undefined;

    const trimmed = raw.trim();
    if (!trimmed) return undefined;

    // 解码并规范化以捕获混淆尝试
    let decoded: string;
    try {
        decoded = decodeURIComponent(trimmed);
    } catch {
        // 格式错误的百分号编码 -- 拒绝
        return undefined;
    }

    // 剥离浏览器可能忽略的空白和控制字符
    const normalized = decoded.replace(/[\s\x00-\x1f]/g, "").toLowerCase();

    // 仅允许 http 和 https 协议
    if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
        return trimmed;  // 返回原始(未解码的)URL
    }

    // 允许协议相对 URL(解析为页面的协议)
    if (normalized.startsWith("//")) {
        return trimmed;
    }

    // 允许相对路径(无协议)
    if (!normalized.includes(":")) {
        return trimmed;
    }

    // 拒绝其他所有内容(javascript:、data:、vbscript: 等)
    return undefined;
}

完整的链接处理器

const handlers = createPipeHandlers({
    link: {
        inline(args, ctx) {
            const rawUrl = args.text(0);
            const url = sanitizeUrl(rawUrl);
            const displayTokens = args.materializedTailTokens(1);

            // 如果 URL 被拒绝,将显示文本输出为纯内容
            // (如果没有提供显示文本,则回退到原始 URL 文本)
            if (url === undefined) {
                return {
                    type: "text",
                    value: displayTokens.length > 0 ? displayTokens : rawUrl,
                };
            }

            return {
                type: "link",
                url,
                value: displayTokens.length > 0 ? displayTokens : [{type: "text", value: url, id: ""}],
            };
        },
    },
});

攻击示例

输入 rawUrl sanitizeUrl 结果 输出
$$link(https://safe.com | click)$$ "https://safe.com" "https://safe.com" url 为 "https://safe.com" 的链接 token
$$link(javascript:alert(1) | click)$$ "javascript:alert(1)" undefined 纯文本 "click"(链接被丢弃)
$$link(data:text/html,<script>... | click)$$ "data:text/html,..." undefined 纯文本 "click"(链接被丢弃)
$$link(JAVASCRIPT:alert(1) | click)$$ "JAVASCRIPT:alert(1)" undefined 纯文本 "click"(大小写不敏感检查)
$$link(java\x00script:alert(1) | click)$$ "java\x00script:..." undefined 纯文本 "click"(规范化时控制字符被剥离)
$$link(| no url)$$ "" undefined 纯文本 "no url"(空 URL 被拒绝)

为什么在处理器中清理,而不是在渲染器中?

你可能会想:"我在渲染 HTML 时再清理就好了。" 这样可以,但在解析时清理有以下优势:

  1. 纵深防御 -- 即使渲染器存在 bug,危险的 URL 也永远不会进入 token 树。
  2. 行为一致 -- token 树的每个消费者(Web 渲染器、移动端渲染器、通知文本、API 响应)都能获得安全的 URL,无需各自实现清理逻辑。
  3. 审核可见性 -- 你可以记录 URL 被拒绝的情况,为审核工具提供更多信号。

第 5 步:内容长度与速率限制(解析器之外)

解析器处理的是语法安全。内容策略 -- 长度限制、垃圾信息检测、速率限制、敏感词过滤 -- 是你的应用程序的职责。解析器为内容策略提供了一个有用的工具:stripRichText(或 dsl.strip())。

为什么需要 strip 来检查长度

如果用户发送:

$$bold($$italic($$underline(hello)$$)$$)$$

原始输入是 47 个字符。实际可见文本是 5 个字符:hello。如果你对原始输入执行 500 字符限制,用户可能发送包含 500 个字符标记但只有 10 个字符内容的消息。反之,如果你只检查原始字符串,使用大量格式化的用户可能会过早触发限制。

使用 dsl.strip() 获取纯文本长度用于策略检查:

function checkContentLength(input: string, maxLength: number): { ok: boolean; plainLength: number } {
    const plainText = chatParser.strip(input);
    return {
        ok: plainText.length <= maxLength,
        plainLength: plainText.length,
    };
}

高效组合 strip 和 parse

如果你同时需要 token(用于渲染)和纯文本(用于长度检查),避免解析两次。调用一次 parse,然后对结果使用 extractText

import { extractText } from "yume-dsl-rich-text";

function processMessage(input: string) {
    const errors: ParseError[] = [];
    const tokens = chatParser.parse(input, {
        onError: (e) => errors.push(e),
    });
    const plainText = extractText(tokens);
    const plainLength = plainText.length;

    return { tokens, plainText, plainLength, errors };
}

内容策略管道

interface PolicyResult {
    allowed: boolean;
    reason?: string;
}

function checkContentPolicy(input: string): PolicyResult {
    // 1. 原始输入长度(防止超大负载)
    if (input.length > 10_000) {
        return { allowed: false, reason: "消息过长(原始输入)" };
    }

    // 2. 纯文本长度(实际内容)
    const plainText = chatParser.strip(input);
    if (plainText.length > 2_000) {
        return { allowed: false, reason: "消息过长(内容)" };
    }
    if (plainText.length === 0) {
        return { allowed: false, reason: "消息为空" };
    }

    // 3. 标记与内容的比例(检测标记垃圾信息)
    const ratio = input.length / plainText.length;
    if (ratio > 10) {
        return { allowed: false, reason: "标记过多" };
    }

    return { allowed: true };
}

第 6 步:整合所有内容

以下是完整的聊天消息处理管道,结合了前面步骤中的每个概念:

import {
    createParser,
    createSimpleInlineHandlers,
    createPipeHandlers,
    extractText,
    type PipeArgs,
    type TokenDraft,
    type TextToken,
    type ParseError,
} from "yume-dsl-rich-text";

// --- URL 清理 ---

function sanitizeUrl(raw: string): string | undefined {
    if (!raw) return undefined;
    const trimmed = raw.trim();
    if (!trimmed) return undefined;

    let decoded: string;
    try {
        decoded = decodeURIComponent(trimmed);
    } catch {
        return undefined;
    }

    const normalized = decoded.replace(/[\s\x00-\x1f]/g, "").toLowerCase();

    if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
        return trimmed;
    }
    if (normalized.startsWith("//")) {
        return trimmed;
    }
    if (!normalized.includes(":")) {
        return trimmed;
    }
    return undefined;
}

// --- 解析器配置 ---

const chatParser = createParser({
    handlers: {
        ...createSimpleInlineHandlers(["bold", "italic", "underline", "strike", "code"]),
        ...createPipeHandlers({
            link: {
                inline(args, ctx) {
                    const rawUrl = args.text(0);
                    const url = sanitizeUrl(rawUrl);
                    const display = args.materializedTailTokens(1);

                    if (url === undefined) {
                        return {
                            type: "text",
                            value: display.length > 0 ? display : rawUrl,
                        };
                    }
                    return {
                        type: "link",
                        url,
                        value: display.length > 0 ? display : [{ type: "text", value: url, id: "" }],
                    };
                },
            },
        }),
    },
    allowForms: ["inline"],
    depthLimit: 10,
});

// --- 消息处理管道 ---

interface ProcessedMessage {
    tokens: TextToken[];
    plainText: string;
    errors: ParseError[];
    flagged: boolean;
    flagReason?: string;
}

function processMessage(input: string): ProcessedMessage {
    // 第 1 步:原始输入长度守卫
    if (input.length > 10_000) {
        return {
            tokens: [{ type: "text", value: "[消息过长]", id: "err-0" }],
            plainText: "",
            errors: [],
            flagged: true,
            flagReason: "原始输入超过 10,000 个字符",
        };
    }

    // 第 2 步:带错误收集的解析
    const errors: ParseError[] = [];
    const tokens = chatParser.parse(input, {
        onError: (e) => errors.push(e),
    });

    // 第 3 步:提取纯文本(单次遍历 -- 不重新解析)
    const plainText = extractText(tokens);

    // 第 4 步:内容策略检查
    if (plainText.length === 0) {
        return {
            tokens,
            plainText,
            errors,
            flagged: true,
            flagReason: "解析后消息为空",
        };
    }

    if (plainText.length > 2_000) {
        return {
            tokens,
            plainText,
            errors,
            flagged: true,
            flagReason: "内容超过 2,000 个字符",
        };
    }

    // 第 5 步:从解析错误中获取审核信号
    const depthErrors = errors.filter((e) => e.code === "DEPTH_LIMIT");
    if (depthErrors.length > 0) {
        return {
            tokens,
            plainText,
            errors,
            flagged: true,
            flagReason: `深度限制被触发 ${depthErrors.length} 次`,
        };
    }

    if (errors.length > 5) {
        return {
            tokens,
            plainText,
            errors,
            flagged: true,
            flagReason: `解析错误过多:${errors.length}`,
        };
    }

    // 第 6 步:标记与内容的比例
    const ratio = input.length / Math.max(plainText.length, 1);
    if (ratio > 10) {
        return {
            tokens,
            plainText,
            errors,
            flagged: true,
            flagReason: `标记比例 ${ratio.toFixed(1)}:1 超过阈值`,
        };
    }

    return { tokens, plainText, errors, flagged: false };
}

使用方法

// 正常消息
const result1 = processMessage("$$bold(Hello)$$ $$italic(world)$$!");
// result1.flagged === false
// result1.tokens 包含 bold + italic token

// 攻击:javascript URL
const result2 = processMessage("$$link(javascript:alert(1) | click me)$$");
// result2.flagged === false(不是错误 -- URL 已被清理)
// 链接 token 的 url 为 undefined,渲染为纯文本 "click me"

// 攻击:raw 形式注入
const result3 = processMessage("$$code(js)%\nalert(1)\n%end$$");
// result3.flagged === false
// result3.tokens 是纯文本(raw 形式被阻止)

// 攻击:嵌套炸弹
const result4 = processMessage("$$bold(".repeat(100) + "x" + ")$$".repeat(100));
// result4.flagged === true
// result4.flagReason 包含 "深度限制被触发"

安全检查清单

本教程涵盖的每一层安全措施的汇总:

状态 措施 负责层 防护内容
已完成 allowForms: ["inline"] 解析器配置 Block/raw 形式注入
已完成 链接处理器中的 URL 清理 标签处理器 javascript:data: 及其他危险 URL 协议
已完成 解析器永不抛出异常 解析器核心 格式错误输入导致的拒绝服务
已完成 onError 回调 解析器配置 监控和审核信号
已完成 depthLimit(降低至 10) 解析器配置 嵌套炸弹攻击
已完成 stripRichText / extractText 应用代码 忽略标记的准确内容长度检查
已完成 标记比例检查 应用代码 标记垃圾信息 / 填充攻击
已完成 原始输入长度上限 应用代码 超大负载导致的内存耗尽

不属于解析器职责的内容

以下关注点必须由你的应用程序或渲染层处理:

关注点 负责层 原因
HTML 转义 你的渲染器(如 Vue、React) 解析器生成 token,而非 HTML。通过 HTML 注入的 XSS 是渲染层的关注点。
速率限制 你的 API 层 解析器是无状态的 -- 它不了解请求频率。
垃圾信息检测 你的审核系统 内容级策略(敏感词、恶意域名链接等)需要解析器不具备的领域知识。
图片/媒体验证 你的媒体管道 如果你添加了 img 标签,URL 验证是必要的但不充分的 -- 你需要验证资源本身是安全的。
会话/认证检查 你的 API 层 解析器不知道谁在发送消息。
⚠️ **GitHub.com Fallback** ⚠️