2. 输出编码

输出编码 学习卡片

为什么“过滤输入”不等于“安全输出”

例1 的白名单校验能挡住大部分非法注册参数,但评论、昵称、个人简介这类字段往往必须允许用户输入 <、>、&、引号等字符。真正的防线在输出时刻:同一段用户数据,放进 HTML 文本、属性值、URL 参数时,危险字符的“逃逸方式”完全不同,必须按上下文分别编码。

  • HTML 文本上下文:<script> 会被解析为标签,需转义 & < >。
  • HTML 属性上下文:" 或 ' 可闭合属性并注入事件处理器,需额外转义引号。
  • URL 上下文:javascript: 伪协议、& 参数分隔符、# 片段都会改变链接语义,需做 URL 编码并校验协议白名单。
用户评论数据 → 按输出上下文分流编码 用户评论原文 <img src=x onerror=alert(1)> ① HTML 文本上下文 <p>{{ comment }}</p> 转义 & < > &lt; &gt; &amp; ② HTML 属性上下文 <div title="{{ x }}"> 额外转义 " ' &quot; &#39; ③ URL 上下文 <a href="/p?q={{ x }}"> URL 编码 + 协议白名单 %3C %22 拒绝 javascript: 浏览器渲染结果 &lt;img src=x onerror=...&gt; ✓ 显示为纯文本,不执行 属性值被安全包裹 title="&quot; onmouseover=..." ✓ 引号被转义,无法闭合属性 链接参数被编码 /p?q=%3Cimg%20src%3Dx%3E ✓ 作为普通查询值传递 ⚠ 同一份数据在不同上下文需要不同编码器;用错上下文 = 转义无效 = XSS 仍然可执行

服务端渲染评论的上下文编码实现

下面用 Node.js 实现一个最小可运行的服务端渲染示例。核心是三个独立编码函数,分别对应三种上下文,绝不混用。

// context-encode.js
// ① HTML 文本上下文:转义 & < >
function escapeHtmlText(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

// ② HTML 属性上下文:在文本基础上额外转义引号
function escapeHtmlAttr(str) {
  return escapeHtmlText(str)
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// ③ 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="&quot; onmouseover=&quot;alert(2)">&quot; onmouseover=&quot;alert(2)</h4>
  <p>&lt;img src=x onerror=alert(1)&gt;</p>
  <a href="#">作者主页</a>
  <a href="/search?q=%3Cscript%3Ealert(4)%3C%2Fscript%3E">搜索相关</a>
</article>
落点原始恶意输入编码后浏览器行为
HTML 文本<img src=x onerror=alert(1)>&lt;img ...&gt;显示为文本,不创建 img 元素
属性值" onmouseover="alert(2)&quot; onmouseover=&quot;...引号被转义,无法闭合 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 绕过。编码函数必须与输出落点一一对应。