#背景
内容站的攻击面很集中:所有用户可控的内容,最终都会经过 Markdown 渲染输出成 HTML。后台只有管理员能写文章,看起来风险不高,但评论、询价留言、AI 生成的内容都会流进同一套渲染逻辑;而"只有管理员能写"这个前提,在多人协作或多角色后台里随时会变。
所以渲染器必须按"输入不可信"来写。以下是实际踩过并修掉的四个坑。
#四个坑
#1. 只转义了尖括号,漏了链接协议
// 错误:链接地址直接输出
$html = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2">$1</a>', $text);
// 输入 [点这里](javascript:alert(document.cookie)) 就中招正确做法是协议白名单,不在名单里的一律降级为纯文本(不要只做字符串替换,java\nscript: 这类变体绕得过去):
$href = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if ($scheme !== '' && !in_array($scheme, ['http', 'https', 'mailto'], true)) {
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); // 不允许的协议:只输出文字
}相对路径(/posts/xxx)的 parse_url 取不到 scheme,属于合法情况,所以要允许 scheme === ''。
#2. 图片的 onerror 与 srcset
) 这种注入靠的是属性值没有转义引号。转义要覆盖 "、'、<、>、& 五个字符(ENT_QUOTES),并且自己拼接属性时不要再手工加引号:
$src = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
$alt = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
return '<img src="' . $src . '" alt="' . $alt . '" loading="lazy" decoding="async">';#3. 原始 HTML 直通
很多渲染器允许 Markdown 里嵌 HTML(方便加 <div>、<iframe>)。一旦直通,前面所有转义都白做——<img src=x onerror=...> 直接过。
取舍很清楚:要么关掉原始 HTML,要么对允许的标签做白名单清洗。本项目选前者:正文不支持原始 HTML,需要嵌入时用受控语法(视频用平台组件、代码用围栏块)。少一个功能,少一整类漏洞。
#4. 代码块里的内容被当标签解析
代码块内的 <script> 必须原样展示,因此渲染顺序很关键:先把代码块抽出来占位,其余部分转义,最后再把代码块内容以转义后的形式放回。顺序颠倒就会出现"代码高亮把标签注入了"这种低级但常见的漏洞。
#验证方法
渲染器的安全测试不能靠肉眼看页面,要写成断言:
foreach ([
'[x](javascript:alert(1))',
')',
'<img src=x onerror=alert(1)>',
'[x](data:text/html;base64,PHNjcmlwdD4=)',
] as $payload) {
$html = MarkdownService::render($payload);
assert(!str_contains($html, 'onerror'));
assert(!str_contains($html, 'javascript:'));
}再加一层兜底:全站 CSP 里 script-src 保持 'self'(不写 unsafe-inline),即使渲染器漏了一处,内联脚本也执行不了。
#取舍
- 不支持原始 HTML:少了排版自由度,换来可证明的边界——这是内容站该做的选择。
- 图片地址不做代理:外链图片直出会有隐私与失效问题,但代理会引入带宽与存储成本;本站的做法是后台配图一律走素材库(本地路径),正文外链只做协议校验。
- CSP 不是替代品:它拦的是"执行",拦不住"内容被篡改"。两层都要有。
评论
还没有评论,来说点什么吧。