3. 跨站脚本(XSS)的上下文输出编码修复
漏洞代码与输出上下文分析
下面是一段典型的“把用户输入直接拼进 HTML”的留言板渲染代码。它的问题不在于是否过滤了 <script>,而在于输出位置决定了需要哪种编码:同一个字符串放进 HTML 文本、属性值、URL 或 JavaScript 里,危险字符和编码规则完全不同。
// 漏洞版本:用户输入直接进入 HTML 文本上下文
app.get('/board', (req, res) => {
const name = req.query.name || 'guest';
const msg = req.query.msg || '';
res.send(`
<div class="card">
<h4>${name}</h4>
<p>${msg}</p>
<a href="/reply?to=${name}">回复</a>
</div>
`);
});
这段代码里存在三个不同的输出上下文,各自需要不同的处理:
${name}与${msg}位于 HTML 文本上下文,需要 HTML 实体编码,阻断<img src=x onerror=...>这类标签注入。href="/reply?to=${name}"位于 HTML 属性上下文,除实体编码外还要防止属性闭合(引号逃逸)。- 该
href同时是 URL 上下文,若用户输入javascript:alert(1),即使编码正确也可能形成可点击的脚本链接,需要协议白名单。
常见误区:只对
< 和 > 做替换,或统一做一次“HTML 转义”就认为安全。属性中的引号、URL 中的协议、JS 中的引号逃逸都不在 HTML 文本编码的覆盖范围内。修复实现:按上下文分别编码
修复的关键是把“拼接”改成“先编码、再拼接”,并针对 URL 增加协议白名单。下面给出可直接运行的 Node.js/Express 修复版本,同时提供一个不依赖框架的编码函数,便于在其他语言中对照实现。
// 修复版本:按输出上下文分别编码
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function safeUrl(raw) {
const s = String(raw);
// 仅允许相对路径或 http/https 协议
if (/^(https?:)?\/\//i.test(s) || s.startsWith('/')) {
return encodeURIComponent(s);
}
return '';
}
app.get('/board', (req, res) => {
const name = req.query.name || 'guest';
const msg = req.query.msg || '';
const safeName = escapeHtml(name); // HTML 文本 + 属性
const safeMsg = escapeHtml(msg); // HTML 文本
const safeTo = safeUrl(name); // URL 参数
res.send(`
<div class="card">
<h4>${safeName}</h4>
<p>${safeMsg}</p>
<a href="/reply?to=${safeTo}">回复</a>
</div>
`);
});
注意 safeUrl 中先做协议白名单判断,再做 encodeURIComponent。如果顺序反过来,javascript:alert(1) 会被编码成 javascript%3Aalert(1),虽然不再直接执行,但白名单判断也会失效,因此判断必须基于原始值。
验证:构造 payload 并检查输出
用以下三类 payload 分别请求修复前后的接口,观察返回 HTML 与浏览器行为。验证目标是:修复后页面不弹窗、DOM 中不出现可执行脚本节点、链接协议被限制。
| payload | 目标上下文 | 修复前预期 | 修复后预期 |
|---|---|---|---|
<img src=x onerror=alert(1)> | HTML 文本 | 弹窗 | 显示为文本,无弹窗 |
" onmouseover="alert(1) | HTML 属性 | 属性逃逸,悬停弹窗 | 引号被编码,属性不闭合 |
javascript:alert(1) | URL | 点击链接执行脚本 | 协议被拒绝,链接为空 |
验证命令示例(使用 curl 观察原始响应,确认编码是否生效):
curl -s "http://localhost:3000/board?name=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E&msg=hi"
# 修复后响应中应出现 <img src=x onerror=alert(1)> 而不是原始标签
如果使用模板引擎(如 EJS、Handlebars、Vue、React),优先使用其默认的自动转义输出(
<%= %>、{{ }}、{value}),不要用 <%- %>、{{{ }}} 或 dangerouslySetInnerHTML 输出用户数据。框架的自动转义通常已覆盖 HTML 文本与属性上下文,但 URL 协议白名单仍需自行处理。快速检查清单
- 确认每个用户可控变量最终落在哪个输出上下文(HTML 文本 / 属性 / URL / JS / CSS)。
- HTML 文本与属性统一使用实体编码,属性值始终用引号包裹。
- URL 参数先做协议白名单,再做 URL 编码。
- 避免使用
innerHTML、document.write直接写入用户数据,改用textContent。 - 用真实 payload 回归验证,检查响应源码与浏览器 DOM,而不是只看页面是否“看起来正常”。