XSS 跨站脚本攻击
XSS 的本质是让本应作为数据的不可信内容跨过边界成为浏览器可执行代码;覆盖 React 转义、危险 DOM sink、CSP 与 CSRF 的边界。
[!info] related notes
- 所属 MOC: 安全 MOC, 浏览器 JS MOC
- 上位主题: 浏览器中的 JavaScript
- 关系笔记: XSS vs CSRF
- 相关概念: CSRF 跨站请求伪造, cross-origin, same-origin
- 防御机制: CSP 内容安全策略, HttpOnly Cookie
- 浏览器安全基础: browser
XSS 跨站脚本攻击
XSS = Cross-Site Scripting。它不是单纯“输入里出现了 <script>”,而是不可信数据进入了会被浏览器解释为代码/活动内容的执行上下文。
心智模型:Data → Code Boundary
最稳的理解是:
不可信输入
用户输入 / URL / API / 数据库 / Markdown / 第三方内容
↓
安全数据上下文
textContent / React 普通文本插值
↓
仍然只是 data
---------------- trust boundary ----------------
不可信输入
↓
危险 HTML / JS / URL sink
innerHTML / dangerouslySetInnerHTML / document.write / 某些动态脚本或 URL API
↓
浏览器把它解释成 markup / code
↓
XSS
所以 XSS 的本质可以记成:
攻击者想办法把“本来应该只是数据的字符串”跨过解释边界,变成浏览器会执行的代码。
面试时可以这样说:
“我通常把 XSS 看成 data-to-code boundary 问题。输入是否包含
<script>不是关键,关键是这段不可信数据最终进入了什么 sink。只要输出仍然处在正确转义的文本上下文,它就是数据;一旦进入innerHTML、dangerouslySetInnerHTML、危险 URL 或第三方 raw HTML renderer,就可能变成活动内容。”
三种常见类型
反射型 XSS
恶意 payload 从当前请求进入,例如 URL/query/form,服务端或前端未经正确上下文处理又把它反射回页面执行。通常需要诱导用户打开构造链接。
存储型 XSS
恶意内容先被持久化到数据库、评论、资料等位置,之后其他用户读取时执行。因为污染内容会被反复分发,影响面通常更大。
DOM-based XSS
不一定经过服务端。前端 JavaScript 从 location、DOM、postMessage 等来源读取不可信值,再写入危险 DOM sink,从而在浏览器内完成注入。
React 默认为什么相对安全
React 普通 JSX 文本插值默认会把字符串当数据处理:
const text = '<img src="bad" onerror="alert(1)">';
return <div>{text}</div>;
这里 text 会显示为文本,而不会因为字符串长得像 HTML 就自动创建真正的 <img> 节点。
这意味着:
{untrustedText}
→ React escaping
→ text node
→ 不会仅凭 HTML-looking string 变成 DOM markup
攻击者不能简单通过“构造更花哨的 <script> 字符串”来绕过这个文本转义边界。
dangerouslySetInnerHTML 为什么危险
return (
<div
dangerouslySetInnerHTML={{
__html: untrustedHtml,
}}
/>
);
这里开发者明确要求 React 把字符串交给 HTML parser。此时:
untrusted string
→ HTML parser
→ real DOM markup
→ event handler / dangerous URL / active content 可能获得执行机会
所以这个 API 的 dangerously 不是装饰词,而是在提醒:你主动绕过了 React 默认的文本转义边界。
如果业务确实需要渲染用户/第三方 HTML,应使用针对 HTML 上下文设计并正确配置的 sanitizer,而不是把“过滤几个 <script> 标签”当作可靠防御。
常见危险 Sink
element.innerHTML = untrusteddangerouslySetInnerHTML={{ __html: untrusted }}document.write(untrusted)- 启用了 raw HTML 的 Markdown/富文本渲染器
- 未经约束的脚本 URL、事件属性或其他可执行 URL/DOM API
- 第三方组件把传入字符串再次解释成 HTML
不同 sink 的安全规则不同,因此“统一把输入 replace 一遍”通常不是可靠的上下文安全模型。
React 默认转义为什么仍不等于“没有 XSS”
React 只帮助保护它正常掌控的 JSX 文本/属性渲染路径。应用仍可能通过 escape hatch 或第三方库重新打开执行边界,例如:
React 普通文本 相对安全默认值
↓
dangerouslySetInnerHTML / raw HTML renderer / unsafe DOM API
↓
重新进入需要额外 trust/sanitization 的边界
此外还要关注 URL scheme、外部脚本、第三方依赖、富文本和浏览器 API,而不能把“用了 React”当作完整 XSS 防御。
CSP 的位置:Defense in Depth
CSP 可以限制脚本/资源从哪里加载、inline script 是否允许等,从而降低某些 XSS payload 最终成功执行的机会。
但关系应该是:
正确输出编码 / 安全渲染 / 必要的 sanitization
↓
第一道 trust boundary
↓
CSP
↓
defense in depth
而不是:
不可信 HTML 直接注入
+ CSP
= 这段 HTML 已经可信 ✗
即使 CSP 恰好拦住某个 onerror,也只能说明这一层策略挡住了特定执行路径,不能证明危险 sink 已经变安全。
HttpOnly 能解决什么、不能解决什么
HttpOnly Cookie 能降低 XSS 通过 document.cookie 直接窃取 Cookie 凭证的风险,但 XSS 仍可能:
- 在当前页面上下文内代表用户发请求
- 读取页面可见数据和 JavaScript 可读状态
- 篡改 UI、诱导操作
- 窃取 localStorage 中的 bearer token
所以 HttpOnly 是减少凭证直接外泄的一层,不是 XSS 防火墙。
与 CSRF 的关系
XSS 本页只维护“Data → Code Boundary”及其防御。与 CSRF 在攻击条件、认证凭证和防御手段上的系统对比统一维护在 XSS vs CSRF。
防御思路
- 默认把外部内容当不可信数据,而不是 HTML
- 优先使用 React JSX 文本插值 /
textContent等安全默认值 - 避免危险 DOM sink;必须使用时进行正确的上下文 sanitization
- 对富文本 / Markdown 的 raw HTML 能力做显式 trust review
- 设置严格 CSP 作为 defense in depth
- 敏感长期 Cookie 使用 HttpOnly,降低凭证直接被脚本读取的风险
- 不在 localStorage 长期保存高价值 bearer credential,除非明确接受相应 XSS 风险
- 审查第三方依赖和动态脚本来源
常见误区
- “只要过滤
<script>就没有 XSS” → XSS 有多种 HTML/DOM/URL 执行上下文。 - “React 会自动执行
{userInput}里的 HTML” → 普通文本插值默认会转义,不会直接生成对应 DOM markup。 - “用了 React 就没有 XSS” → escape hatch、raw HTML renderer、危险 URL/API 仍可能重新打开边界。
- “CSP 可以替代 sanitization” → CSP 是纵深防御,不会把不可信 HTML 本身变可信。
- “HttpOnly 防住了 XSS” → 只能阻止 JS 直接读取该 Cookie 值,攻击脚本仍可能借当前页面身份做很多事。
最短记忆方式
先问数据最终进入哪个 sink:安全文本上下文让它保持 data;危险 HTML/JS/URL sink 可能让它跨过边界变成 code。
学习检查
不看正文回答:
- 为什么 XSS 的核心不是字符串里有没有
<script>,而是“不可信数据最终进入了什么 sink”? - 反射型、存储型、DOM-based XSS 的 payload 来源和执行路径分别有什么差异?
- React 普通 JSX 插值为什么默认更安全?哪些 escape hatch 会重新打开风险?
- CSP、sanitization、HttpOnly 分别处在什么防御层,为什么不能互相替代?
- 为什么 localStorage 中的 bearer token 在 XSS 模型下是高价值目标?
- 给一个 Markdown 渲染、一个 URL 跳转、一个
dangerouslySetInnerHTML场景,分别说明需要审查的执行边界。 - 如果问题是在比较 XSS 与 CSRF,为什么应该跳到 XSS vs CSRF 而不是继续扩充本页?