flawopen.com/xss/Javascript
●
CWE-79 · 高危
安全研究 · FlawOpen
JavaScript XSS:保护 DOM、React 与 Express
不安全的 DOM/React 接口和链接可能执行 JavaScript。优先使用文本 API,清理 HTML、验证 URL 并启用 Trusted Types(CWE-79)。
💡 通俗易懂的原理解析 (ELI5)
想象一个剧场提示台。如果舞台监督把观众的每张便条都当作舞台指令,便条就可能添加意外的场景;若将它作为普通对白展示,它就只是文本。在浏览器中,innerHTML 和 React 的 dangerouslySetInnerHTML 会让 DOM 把输入解析为标记,而 textContent 会按原样显示。普通文本应转义;只对明确允许的少量 HTML 做清理;创建链接前验证 URL 协议。
核心概念与专有名词
innerHTML vs textContent- 给
innerHTML、outerHTML或insertAdjacentHTML赋值会让浏览器把字符串当作 HTML 解析,其中的<img onerror>就成了活的元素。textContent和createTextNode只插入文本,值中的任何内容都不会被解析为标记。 Template literals in res.send()- Express 的
res.send()默认以text/html发送字符串。模板字符串在发送前就对${req.query.q}求值,因此响应里包含用户在查询字符串中放入的任何内容。req.query.q还可能是数组或对象,所以要检查类型。 dangerouslySetInnerHTML + DOMPurify- React 会自动转义 JSX 中的文本,
dangerouslySetInnerHTML是显式的例外。把值交给DOMPurify.sanitize(),它用浏览器自带的解析器解析内容,并在 React 插入之前删除脚本、事件处理器和危险 URL。 javascript: URLs in href- JSX 会转义属性值,但
href="javascript:..."是合法的 URL,点击时就会执行代码。用new URL()解析,只允许http:和https:;URL 解析器会去掉开头的空格和制表符,而简单的前缀检查会漏掉它们。 Trusted Types- 启用 CSP 指令
require-trusted-types-for 'script'后,支持的浏览器会拒绝向innerHTML等接收点写入普通字符串,只接受由你定义的策略(通常会调用 DOMPurify)创建的TrustedHTML对象。新增的接收点不会悄悄混进来。
框架映射
- 本指南所述弱点的 CWE 参考: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
攻击执行流程分解
步骤 1
输入到达接收点
搜索词、从 API 加载的个人资料字段或其他用户保存的链接以字符串形式到达,并被交给 innerHTML、dangerouslySetInnerHTML、href 或 res.send()。
步骤 2
值变成了标记
使用无害的测试值 <img src=x onerror=alert(1)> 时,innerHTML 和 res.send() 会生成一个真实的 <img> 元素;保存下来的 javascript:alert(1) 链接则成为可点击的脚本。
步骤 3
浏览器执行了它
图片加载失败并触发 onerror,或者用户点击了链接。代码立即执行,攻击者无需任何后续操作。
步骤 4
脚本以网站的身份行事
它以页面的源(origin)运行:可以读取 DOM,用受害者的 cookie 或 localStorage 中的令牌调用网站 API,并篡改用户看到的内容。
源代码对比:漏洞与安全实现
存在漏洞的实现
// profile.jsx:下面每个函数都让字符串被当作 HTML 解析
import express from 'express';
const app = express();
// Express:发送之前查询值就被放进了 HTML
app.get('/search', (req, res) => {
res.send(`<h1>Results for ${req.query.q}</h1>`);
});
// innerHTML 把 name 当作标记解析
export function showGreeting(el, name) {
el.innerHTML = `Hello, ${name}`;
}
// React 原样插入收到的 HTML
export function Bio({ html }) {
return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}
// 点击链接时 javascript: URL 就会执行
export function Website({ url }) {
return <a href={url}>Website</a>;
}
加固后的安全修复
// profile.jsx:文本始终是文本;HTML 和 URL 先经过检查
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';
const app = express();
// 只接受字符串,并对其做 HTML 转义
app.get('/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q : '';
res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});
// textContent 从不解析标记
export function showGreeting(el, name) {
el.textContent = `Hello, ${name}`;
}
// DOMPurify 在插入前删除脚本和事件处理器
export function Bio({ html }) {
return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}
// 只保留 http 和 https URL,其余都变成 #
function safeHref(url) {
try {
const parsed = new URL(url, window.location.origin);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
} catch {
return '#';
}
}
export function Website({ url }) {
return <a href={safeHref(url)}>Website</a>;
}
工程与系统安全加固清单
- ✓在 CI 中启用
react/no-danger和eslint-plugin-no-unsanitized,禁止向innerHTML、outerHTML、insertAdjacentHTML和document.write传入非字面量的值。 - ✓文本一律用
textContent;必须渲染的 HTML 在插入处先经过DOMPurify.sanitize()。 - ✓所有用户提供的 URL 在进入
href、src或window.location之前,都要用new URL()和http:/https:白名单校验。 - ✓在 Express 中通过自动转义的视图引擎或
escape-html渲染,并拒绝非字符串的查询参数。 - ✓发送基于 nonce 的
script-src、不含'unsafe-inline'并包含require-trusted-types-for 'script'的 CSP;会话令牌放在HttpOnlycookie 中,而不是localStorage。