3. 跨站脚本(XSS)的上下文输出编码修复

跨站脚本(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 文本编码的覆盖范围内。
用户输入 → 输出上下文 → 编码策略 → 验证 用户输入 name / msg HTML 文本上下文 <p>${msg}</p> 危险:< > & 标签注入 HTML 属性上下文 href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false" 危险:" ' 属性闭合 URL 上下文 /reply?to=${name} 危险:javascript: 协议 HTML 实体编码 &→&amp; <→&lt; >→&gt; "→&quot; 属性编码 + 引号包裹 '→&#39; "→&quot; 始终用双引号包裹属性 URL 编码 + 协议白名单 encodeURIComponent() 仅允许 http/https/相对路径 验证 payload 被编码 无弹窗 DOM 无脚本 核心原则:先确定输出落在哪个上下文,再选择对应编码;编码必须发生在拼接之前,且不能依赖黑名单过滤。 同一变量若出现在多个上下文,需分别编码,不能复用同一次转义结果。

修复实现:按上下文分别编码

修复的关键是把“拼接”改成“先编码、再拼接”,并针对 URL 增加协议白名单。下面给出可直接运行的 Node.js/Express 修复版本,同时提供一个不依赖框架的编码函数,便于在其他语言中对照实现。

// 修复版本:按输出上下文分别编码
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

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"
# 修复后响应中应出现 &lt;img src=x onerror=alert(1)&gt; 而不是原始标签
如果使用模板引擎(如 EJS、Handlebars、Vue、React),优先使用其默认的自动转义输出(<%= %>、{{ }}、{value}),不要用 <%- %>、{{{ }}} 或 dangerouslySetInnerHTML 输出用户数据。框架的自动转义通常已覆盖 HTML 文本与属性上下文,但 URL 协议白名单仍需自行处理。

快速检查清单

  • 确认每个用户可控变量最终落在哪个输出上下文(HTML 文本 / 属性 / URL / JS / CSS)。
  • HTML 文本与属性统一使用实体编码,属性值始终用引号包裹。
  • URL 参数先做协议白名单,再做 URL 编码。
  • 避免使用 innerHTML、document.write 直接写入用户数据,改用 textContent。
  • 用真实 payload 回归验证,检查响应源码与浏览器 DOM,而不是只看页面是否“看起来正常”。