zh CN 教程 安全UGC - chiba233/yumeDSL GitHub Wiki
你有一个聊天或评论系统。用户输入消息。你希望允许简单的格式化 -- 粗体、斜体、链接 -- 但要防止滥用:
- 不允许 block 或 raw 标签(可能破坏布局或注入原始内容)
- 未知标签应显示为纯文本,而非抛出错误
- 格式错误的标记绝不能导致解析器崩溃
- URL 必须经过清理(禁止
javascript:或data:协议) - 为审核工具提供错误报告
本教程将逐步引导你使用 yume-dsl-rich-text 构建一个完整的、可用于生产环境的 UGC 聊天消息处理管道。每个步骤都包含可运行的代码和攻击场景测试,让你能够自行验证安全特性。
第一道防线是限制解析器接受 哪些标签 和 哪些标签形式。聊天系统通常只需要 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 选项是 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(对 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" }]
// 整个输入都是纯文本。没有任何标签被识别。解析器的设计原则是对格式错误的输入绝不抛出异常。每个语法错误、每个未知标签、每次嵌套滥用都会降级为纯文本。这对 UGC 至关重要:你无法预测用户会输入什么,而崩溃意味着拒绝服务。
以下是关键的降级场景及其准确输出:
chatParser.parse("$$unknown(hello)$$");
// 结果:
// [{ type: "text", value: "hello", id: "rt-0" }]标签 unknown 不在 handlers 映射中。解析器识别了语法但没有对应的处理器,因此内容 "hello" 被展开为纯文本。$$unknown( 和 )$$ 分隔符被剥离,只有内部内容保留。
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。
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 标签解析。
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, // 聊天消息很少需要超过几层嵌套
});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。
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", ... }], ... },
// ]有效的 bold 和 italic 标签正常解析。未注册的 unknown 标签降级为纯文本 "oops"。周围的内容不受影响。
解析器的 onError 回调是你观察格式错误输入的窗口。对于聊天系统,错误数据对审核非常有价值 -- 一条充满解析错误的消息很可能是垃圾信息或攻击尝试。
function parseMessage(input: string) {
const errors: ParseError[] = [];
const tokens = chatParser.parse(input, {
onError: (e) => errors.push(e),
});
return { tokens, errors };
}每个错误包含:
interface ParseError {
code: ErrorCode;
message: string;
line: number;
column: number;
snippet: string;
}| 字段 | 描述 |
|---|---|
code |
机器可读的错误类型(ErrorCode 联合类型) |
message |
人类可读的消息,带有 (L{line}:C{column}) 前缀和 >>>...<<< 片段标记 |
line |
错误开始位置的行号(从 1 开始) |
column |
错误开始位置的列号(从 1 开始) |
snippet |
错误周围的上下文,用 >>> <<< 标记指示问题范围 |
使用 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_CLOSED、RAW_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 错误突然激增是滥用的强烈信号。
解析器不会验证 URL -- 这是处理器的职责。对于 UGC,你必须清理 URL 以防止 javascript:、data: 和其他危险协议。
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 时再清理就好了。" 这样可以,但在解析时清理有以下优势:
- 纵深防御 -- 即使渲染器存在 bug,危险的 URL 也永远不会进入 token 树。
- 行为一致 -- token 树的每个消费者(Web 渲染器、移动端渲染器、通知文本、API 响应)都能获得安全的 URL,无需各自实现清理逻辑。
- 审核可见性 -- 你可以记录 URL 被拒绝的情况,为审核工具提供更多信号。
解析器处理的是语法安全。内容策略 -- 长度限制、垃圾信息检测、速率限制、敏感词过滤 -- 是你的应用程序的职责。解析器为内容策略提供了一个有用的工具:stripRichText(或 dsl.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,
};
}如果你同时需要 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 };
}以下是完整的聊天消息处理管道,结合了前面步骤中的每个概念:
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 层 | 解析器不知道谁在发送消息。 |