2. 输出编码
为什么“过滤输入”不等于“安全输出”
例1 的白名单校验能挡住大部分非法注册参数,但评论、昵称、个人简介这类字段往往必须允许用户输入 <、>、&、引号等字符。真正的防线在输出时刻:同一段用户数据,放进 HTML 文本、属性值、URL 参数时,危险字符的“逃逸方式”完全不同,必须按上下文分别编码。
- HTML 文本上下文:
<script>会被解析为标签,需转义& < >。 - HTML 属性上下文:
"或'可闭合属性并注入事件处理器,需额外转义引号。 - URL 上下文:
javascript:伪协议、&参数分隔符、#片段都会改变链接语义,需做 URL 编码并校验协议白名单。
服务端渲染评论的上下文编码实现
下面用 Node.js 实现一个最小可运行的服务端渲染示例。核心是三个独立编码函数,分别对应三种上下文,绝不混用。
// context-encode.js
// ① HTML 文本上下文:转义 & < >
function escapeHtmlText(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// ② HTML 属性上下文:在文本基础上额外转义引号
function escapeHtmlAttr(str) {
return escapeHtmlText(str)
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ③ URL 上下文:URL 编码 + 协议白名单
function encodeUrlParam(str) {
return encodeURIComponent(String(str));
}
function safeHref(url) {
const raw = String(url).trim();
// 只允许 http/https/相对路径,拒绝 javascript: data: 等伪协议
if (/^(https?:|\/|\.\/|\.\.\/)/i.test(raw)) {
return escapeHtmlAttr(raw);
}
return '#';
}
module.exports = { escapeHtmlText, escapeHtmlAttr, encodeUrlParam, safeHref };
渲染评论时,根据数据落点选择对应函数:
// render-comment.js
const { escapeHtmlText, escapeHtmlAttr, encodeUrlParam, safeHref } = require('./context-encode');
function renderComment(comment) {
const text = escapeHtmlText(comment.content); // 落点:HTML 文本
const author = escapeHtmlAttr(comment.author); // 落点:属性值
const link = safeHref(comment.website); // 落点:href 属性
const search = encodeUrlParam(comment.keyword); // 落点:URL 查询参数
return `
<article class="comment">
<h4 title="${author}">${author}</h4>
<p>${text}</p>
<a href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false">作者主页</a>
<a href="/search?q=${search}">搜索相关</a>
</article>`;
}
// 恶意评论测试数据
const evil = {
content: '<img src=x onerror=alert(1)>',
author: '" onmouseover="alert(2)',
website: 'javascript:alert(3)',
keyword: '<script>alert(4)</script>'
};
console.log(renderComment(evil));
验证恶意脚本无法执行
运行 node render-comment.js,观察输出 HTML 中危险字符是否已被实体化。预期输出片段如下(示例结果,由上述代码直接推出):
<article class="comment">
<h4 title="" onmouseover="alert(2)">" onmouseover="alert(2)</h4>
<p><img src=x onerror=alert(1)></p>
<a href="#">作者主页</a>
<a href="/search?q=%3Cscript%3Ealert(4)%3C%2Fscript%3E">搜索相关</a>
</article>
| 落点 | 原始恶意输入 | 编码后 | 浏览器行为 |
|---|---|---|---|
| HTML 文本 | <img src=x onerror=alert(1)> | <img ...> | 显示为文本,不创建 img 元素 |
| 属性值 | " onmouseover="alert(2) | " onmouseover="... | 引号被转义,无法闭合 title 属性 |
| href 属性 | javascript:alert(3) | # | 协议白名单拒绝,链接失效 |
| URL 查询参数 | <script>alert(4)</script> | %3Cscript%3E...%3C%2Fscript%3E | 作为普通查询值,不进入 HTML 解析 |
验证方法:把渲染结果保存为
output.html,用浏览器打开,检查页面是否只显示文本、控制台无 alert 弹窗、DOM 中不存在注入的 <img> 或 <script> 节点。也可用 document.querySelectorAll('img,script') 确认数量为 0。常见错误:只做一次
escapeHtml 就用于所有位置。属性上下文漏转义引号、URL 上下文漏做协议白名单,都会让 XSS 绕过。编码函数必须与输出落点一一对应。