---
title: Markdown 渲染的 XSS 边界：自研渲染器踩过的四个坑
url: https://vabora.dev/posts/markdown-xss-hardening
author: Vabora
published: 2026-09-08 09:00:00
updated: 2026-09-27 10:33:42
category: 技术分享
tags: PHP 8, 安全, Markdown
canonical: https://vabora.dev/posts/markdown-xss-hardening
license: 转载与引用请注明出处并保留原文链接
---

# Markdown 渲染的 XSS 边界：自研渲染器踩过的四个坑

> 内容站的 XSS 几乎都从 Markdown 渲染进来。自研渲染器要处理的不只是转义，还有链接协议、图片事件、原始 HTML 与属性注入——四个坑与对应写法。
## 背景

内容站的攻击面很集中：**所有用户可控的内容，最终都会经过 Markdown 渲染输出成 HTML**。后台只有管理员能写文章，看起来风险不高，但评论、询价留言、AI 生成的内容都会流进同一套渲染逻辑；而"只有管理员能写"这个前提，在多人协作或多角色后台里随时会变。

所以渲染器必须按"输入不可信"来写。以下是实际踩过并修掉的四个坑。

## 四个坑

### 1. 只转义了尖括号，漏了链接协议

```php
// 错误：链接地址直接输出
$html = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2">$1</a>', $text);
// 输入 [点这里](javascript:alert(document.cookie)) 就中招
```

正确做法是**协议白名单**，不在名单里的一律降级为纯文本（不要只做字符串替换，`java\nscript:` 这类变体绕得过去）：

```php
$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`

`![x](a.png" onerror="alert(1))` 这种注入靠的是**属性值没有转义引号**。转义要覆盖 `"`、`'`、`<`、`>`、`&` 五个字符（`ENT_QUOTES`），并且自己拼接属性时不要再手工加引号：

```php
$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>` 必须原样展示，因此**渲染顺序**很关键：先把代码块抽出来占位，其余部分转义，最后再把代码块内容以转义后的形式放回。顺序颠倒就会出现"代码高亮把标签注入了"这种低级但常见的漏洞。

## 验证方法

渲染器的安全测试不能靠肉眼看页面，要写成断言：

```php
foreach ([
    '[x](javascript:alert(1))',
    '![x](a.png" onerror="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 不是替代品**：它拦的是"执行"，拦不住"内容被篡改"。两层都要有。
