C04 XSS 与 CSP

2026-10-31 14:480 阅读约 12 分钟HTML

摘要:页面把用户输入原样插进 HTML 时,输入里的标签会被浏览器当成结构执行,这就是跨站脚本。风险取决于插入位置:元素内容、属性、URL 与脚本块四种上下文的转义规则互不通用。本文给出四种上下文的正确写法、一份可用的 CSP 策略,以及三段注入演练,验证防护是否生效。
关键词:XSS,内容注入,转义,CSP,子资源完整性

所属:frontend/html / C-高级 | 上一篇:C03-资源提示与性能 | 下一篇:C05-HTML与工程化

1 引言

注入不是脚本层的问题:浏览器解析 HTML 时不区分「这段文字来自数据库」与「这段文字来自模板」。只要内容被拼进 HTML 文本,标签就会生效。

本文覆盖四种上下文的处理方式与一条纵深防护线。范围见表 1-1。

项 本文覆盖 本文不覆盖
注入 元素内容、属性、URL、脚本块四种上下文 服务端框架的自动转义实现
防护 转义、白名单净化、CSP、SRI、rel="noopener" CSRF、SQL 注入等其它风险
验证 三段注入演练与响应头检查 渗透测试与漏洞扫描工具

2 预备知识

项 要求 核对方式
环境 A02 的本地服务器 能访问 localhost
前置 A07 能写表单与提交处理
前置 B03 理解 sandbox 与同源策略
文件 html-practice/xss.html、render.mjs 一个页面 + 一个渲染脚本

3 原理与实现

3.1 四种上下文

同一段恶意字符串,落在不同位置时需要的防护完全不同,如图 3-1 所示。

flowchart TD
    U["用户输入"] --> C{"插入位置"}
    C -->|"元素内容"| C1["HTML 实体转义<br/>&lt; &gt; &amp;"]
    C -->|"属性值"| C2["引号包裹 + 实体转义"]
    C -->|"URL"| C3["协议白名单 + 编码"]
    C -->|"脚本块"| C4["禁止拼接<br/>改用 JSON 序列化"]

图 3-1 按上下文选择防护手段

图上可见:一次转义不能覆盖全部位置;只做 HTML 实体转义时,href="javascript:..." 仍然可以执行。

3.2 危险写法与安全写法

// 运行环境:Node 20+;保存为 render.mjs,执行 node render.mjs
import { writeFileSync } from 'node:fs';

const userInput = process.argv[2] ?? '<img src=x onerror="alert(1)">';

// 不安全:直接拼接
const unsafe = `<p>${userInput}</p>`;

// 安全:HTML 实体转义
const escapeHtml = (s) =>
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

// 安全:URL 只允许 http/https/相对路径
const safeUrl = (u) => (/^(https?:|\.{0,2}\/)/i.test(u) ? escapeHtml(u) : '#');

const page = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; img-src 'self' https: data:; script-src 'self'" />
  <title>注入演练</title>
</head>
<body>
  <h1>输出结果</h1>
  <p>${escapeHtml(userInput)}</p>
  <p><a href="${safeUrl(userInput)}">链接</a></p>
  <!-- 不安全示例,仅供对照:<p>${userInput}</p> -->
  <script src="./app.js"></script>
</body>
</html>`;

writeFileSync('xss.html', page, 'utf8');
console.log('已生成 xss.html');

四种上下文的处理规则见表 3-1。

上下文 危险示例 处理方式
元素内容 <p>${input}</p> 实体转义 < > & "
属性值 <div title=${input}> 引号包裹 + 实体转义
URL <a href="${input}"> 协议白名单,拦截 javascript: 与 data:
脚本块 var name = '${input}' 禁止拼接,改用 JSON 序列化
富文本 直接渲染用户 HTML 白名单净化库,只保留允许的标签与属性

CSP 常用指令见表 3-2。

指令 作用 建议值
default-src 默认资源来源 'self'
script-src 脚本来源 'self',避免 'unsafe-inline'
style-src 样式来源 'self',必要时用哈希替代内联
img-src 图片来源 'self' https: data:
frame-ancestors 允许被谁内嵌(防点击劫持) 'none' 或指定域名
upgrade-insecure-requests 升级 http 子资源 生产环境开启

外部资源的完整性校验与链接安全见表 3-3。

场景 写法
引入第三方脚本 <script src="..." integrity="sha384-..." crossorigin="anonymous">
新窗口打开链接 rel="noopener noreferrer"
内嵌第三方页面 空 sandbox 起步,按需放行(B03)
上传文件回显 只回显服务端生成的地址,不回显原始文件名

4 验证与讨论

  1. 执行 node render.mjs '<img src=x onerror="alert(1)">',打开生成的 xss.html,确认页面显示的是文本而非执行脚本。
  2. 把模板中被注释的不安全示例取消注释,重新生成并打开,确认弹出警告框,理解差异。
  3. 执行 node render.mjs 'javascript:alert(1)',点击生成的链接,确认被替换为 #。
  4. 打开开发者工具 Network 面板查看首页响应头,确认 Content-Security-Policy 已生效;在 Console 中执行 document.body.innerHTML = '<script>alert(1)<\/script>',观察 CSP 是否阻断。
检查项 预期结果 说明
内容注入 显示为文本,无弹窗 实体转义
属性注入 属性值被转义,未新增属性 引号 + 转义
URL 注入 javascript: 被替换为 # 协议白名单
CSP 生效 Console 显示违规被拒绝 script-src 'self'
第三方脚本 篡改内容后校验失败、脚本不执行 SRI

讨论与局限。 meta 形式的 CSP 无法使用 frame-ancestors 等部分指令,生产环境应由响应头下发。另 CSP 不能防住服务端模板拼接错误,转义仍是第一道防线;两者是纵深层级,不是替代关系。

5 常见问题

现象 原因 处理
只转义了尖括号仍被注入 属性未加引号,空格分隔出新的属性 属性值一律用双引号包裹
富文本净化后样式全丢 白名单过严,把 class 也过滤了 按需放行允许的属性
CSP 上线后页面白屏 内联脚本被 script-src 'self' 阻断 改为外链脚本,或用哈希/nonce
第三方脚本加载失败 SRI 哈希与文件不一致(对方已更新) 固定版本地址,更新时同步哈希
新窗口打开的页面能操作原页 缺少 noopener 补 rel="noopener noreferrer"

6 结论

序号 结论 依据
1 注入风险由插入位置决定,转义规则随之不同 图 3-1、表 3-1
2 URL 与富文本必须走白名单,不做黑名单过滤 表 3-1
3 CSP 防内联脚本,转义防拼接,二者叠加 表 3-2
4 第三方资源用 SRI 锁定内容 表 3-3

参考文献

[1] OWASP · Cross Site Scripting Prevention Cheat Sheet,https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html(访问于 2026-10-29)
[2] MDN Web Docs · 内容安全策略 CSP,https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP(访问于 2026-10-29)

返回文章列表

评论 0

0/2000

还没有评论,快来抢沙发吧~