本文面向前端、Node.js 与安全测试初学者。所有实验仅用于你拥有或明确获准测试的系统、本文提供的本地页面,以及 OWASP、PortSwigger 官方训练环境。本文不会提供真实第三方站点清单、Cloudflare 生产挑战绕过、验证码规避、账号窃取或未授权扫描方法。
一、为什么要学习 JavaScript 注入?
前端开发中,“把字符串变成页面”看起来非常普通:
result.innerHTML = userInput;
但当字符串来自 URL、表单、接口、postMessage 或数据库时,它就可能不再只是文本。如果数据最终进入能够解释 HTML 或 JavaScript 的 API,攻击者提供的内容就可能被浏览器当成代码执行。
这类问题通常归入 Cross-Site Scripting(XSS,跨站脚本)。它可能导致:
- 以当前用户身份执行页面操作;
- 读取页面中可访问的敏感信息;
- 修改表单和页面内容;
- 构造钓鱼界面;
- 向外部地址发送数据;
- 在存储型场景中持续影响其他用户。
学习注入的正确目标,不是积累“万能 Payload”,而是建立一套分析方法:
不可信输入(Source)
↓
转换、拼接、解码(Transform)
↓
危险解释位置(Sink)
↓
浏览器是否把数据当成代码?
二、先区分三个容易混淆的概念
2.1 普通 JavaScript 执行
开发者自己编写并加载的脚本属于正常程序逻辑:
document.querySelector("#submit").addEventListener("click", handleSubmit);
2.2 JavaScript 注入
不可信数据被拼入可执行上下文,或被交给能解释代码的 API:
// 危险:不可信字符串被当成 JavaScript 执行
eval(untrustedInput);
2.3 XSS
攻击者利用 Web 应用中的注入问题,使脚本在目标页面的安全上下文中执行。常见类型:
| 类型 | 数据流特点 | 常见入口 |
|---|---|---|
| 反射型 XSS | 请求输入立即进入响应 | 搜索参数、错误提示 |
| 存储型 XSS | 输入先保存,随后展示给其他用户 | 评论、昵称、工单 |
| DOM XSS | 浏览器端脚本把不可信数据传入危险 Sink | location、postMessage |
DOM XSS 的关键点是:危险数据流可能完全发生在浏览器中,服务器返回的原始 HTML 本身未必包含 Payload。
三、常见 Source:不可信数据从哪里来?
下列值都不应被默认信任:
// URL 查询参数
const q = new URLSearchParams(location.search).get("q");
// URL 片段
const fragment = location.hash.slice(1);
// 跨窗口消息
window.addEventListener("message", (event) => {
const message = event.data;
});
// 表单输入
const value = document.querySelector("#nickname").value;
// 网络接口(示例位于异步函数中)
async function loadProfile() {
return fetch("/api/profile").then((r) => r.json());
}
// 本地存储
const cached = localStorage.getItem("draft");
“接口返回”和“数据库数据”也不等于可信。数据可能在更早的入口已经被污染,也可能来自第三方系统。
四、常见危险 Sink 与替代方案
| 危险写法 | 风险 | 更安全的方向 |
|---|---|---|
element.innerHTML = value | 将字符串解析为 HTML | 纯文本用 textContent |
element.outerHTML = value | 替换并解析节点 | 使用 DOM API 构建节点 |
insertAdjacentHTML(...) | 解析不可信 HTML | createElement + append |
document.write(value) | 向文档注入 HTML | 模板渲染或 DOM API |
eval(value) | 直接解释 JavaScript | 明确的函数映射 |
new Function(value) | 动态创建可执行代码 | 预定义函数 |
setTimeout(value, 0) | 字符串参数会被解释为代码 | 传入函数而不是字符串 |
iframe.srcdoc = value | 在 iframe 中解析 HTML | 固定模板、严格清洗与隔离 |
setAttribute("onclick", value) | 创建事件处理代码 | addEventListener |
script.src = value | 加载攻击者控制的脚本 | 固定资源清单 |
最重要的原则:
如果业务只需要展示文本,就永远不要使用 HTML Sink。
五、搭建一个只在本地运行的 DOM XSS 实验
新建目录:
mkdir js-injection-safe-lab
cd js-injection-safe-lab
创建 unsafe.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>本地 DOM XSS 演示</title>
<style>
body { max-width: 760px; margin: 40px auto; font-family: sans-serif; }
textarea { width: 100%; height: 100px; }
#preview { margin-top: 16px; padding: 16px; border: 1px solid #ccc; }
#status { color: #b42318; font-weight: 700; }
</style>
</head>
<body>
<h1>仅限本地实验</h1>
<textarea id="input" aria-label="演示输入"></textarea>
<button id="render">渲染</button>
<p id="status">尚未触发</p>
<div id="preview"></div>
<script>
const input = document.querySelector("#input");
const preview = document.querySelector("#preview");
document.querySelector("#render").addEventListener("click", () => {
// 故意保留的漏洞:不要复制到真实项目
preview.innerHTML = input.value;
});
</script>
</body>
</html>
在该目录启动静态服务器:
python3 -m http.server 8080
打开:
http://127.0.0.1:8080/unsafe.html
为了验证“输入被解释为代码”,只在这个本地页面中输入下面的无害 PoC:
<img src="x" onerror="document.querySelector('#status').textContent='本地 PoC 已触发'">
点击“渲染”后,图片加载失败触发 onerror,状态文字发生变化。这个实验不读取 Cookie、不访问网络、不修改账号,只证明:
textarea.value → innerHTML → HTML 解析 → 事件处理代码执行
实验完成后停止本地服务器,不要把故意脆弱页面部署到公网。
六、修复本地实验:纯文本使用 textContent
如果业务只是展示用户输入,修复非常简单:
document.querySelector("#render").addEventListener("click", () => {
preview.textContent = input.value;
});
此时浏览器会把尖括号和事件属性当成普通文本,不会创建图片节点,也不会执行 onerror。
还可以显式创建元素:
const paragraph = document.createElement("p");
paragraph.textContent = input.value;
preview.replaceChildren(paragraph);
createElement、textContent、append 和 replaceChildren 能让“数据”和“代码结构”保持分离。
七、业务确实需要富文本怎么办?
有些应用必须支持有限的 HTML,例如文章正文、Markdown 预览或富文本评论。这时不能简单地“删除 script 标签”,因为危险内容还可能藏在:
- 事件属性,如
onerror; - 危险 URL Scheme;
- SVG、MathML 等上下文;
iframe、srcdoc;- 浏览器解析差异;
- 编码、解码和二次渲染过程。
推荐使用持续维护的 HTML Sanitizer。以 DOMPurify 为例:
import DOMPurify from "dompurify";
const cleanHtml = DOMPurify.sanitize(untrustedHtml, {
USE_PROFILES: { html: true },
});
container.innerHTML = cleanHtml;
注意:
- Sanitizer 必须保持更新;
- 清洗后不要再次拼接未清洗内容;
- 配置允许的标签和属性应遵循最小化原则;
- 后端存储、导出、邮件模板等不同上下文仍需单独处理;
- 富文本之外的字段继续使用
textContent。
八、不要把用户输入交给 eval
下面的写法不是“灵活”,而是把解释器暴露给输入:
// 错误示例
const action = new URLSearchParams(location.search).get("action");
eval(action);
如果业务需要根据字符串选择动作,应使用显式映射:
const actions = {
showHelp() {
document.querySelector("#help").hidden = false;
},
refreshPreview() {
refreshPreview();
},
};
const actionName = new URLSearchParams(location.search).get("action");
const action = actions[actionName];
if (typeof action === "function") {
action();
} else {
console.warn("未知操作");
}
同样应避免:
new Function(untrustedInput)();
setTimeout(untrustedInput, 0);
setInterval(untrustedInput, 1000);
正确写法是传函数:
setTimeout(() => refreshPreview(), 0);
九、URL 与跳转也需要验证
不要把任意字符串直接用于跳转或资源地址:
// 危险
location.href = userProvidedUrl;
如果业务只允许站内 HTTPS 地址,可以使用 URL 解析和白名单:
function toAllowedUrl(input) {
const url = new URL(input, location.origin);
if (url.origin !== location.origin) {
throw new Error("只允许站内地址");
}
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("不允许的协议");
}
return url.href;
}
location.assign(toAllowedUrl(userProvidedUrl));
验证时应检查解析后的 origin 和 protocol,不要只用字符串 startsWith。
十、postMessage:先验证来源,再处理数据
postMessage 常用于 iframe、支付页和多窗口通信。如果接收端信任任意来源,攻击者页面也可能发送消息。
const ALLOWED_ORIGINS = new Set([
"https://app.example.test",
"https://account.example.test",
]);
window.addEventListener("message", (event) => {
if (!ALLOWED_ORIGINS.has(event.origin)) {
return;
}
if (
typeof event.data !== "object" ||
event.data === null ||
event.data.type !== "PROFILE_UPDATED"
) {
return;
}
// 继续按字段 Schema 验证,而不是直接 innerHTML
status.textContent = "资料已更新";
});
发送端也不要使用通配符目标:
targetWindow.postMessage(message, "https://app.example.test");
十一、在官方靶场中练习,而不是寻找真实脆弱站点
11.1 OWASP Juice Shop
OWASP Juice Shop 是一个故意设计成不安全的现代 Web 应用,包含 DOM XSS、反射型 XSS、API-only XSS 等挑战。它还为部分挑战提供“Find It / Fix It”代码练习。
推荐学习方式:
- 从 OWASP 官方项目页进入;
- 优先在本地运行,或使用官方明确提供的演示环境;
- 开启教程模式;
- 只完成分配给你的挑战;
- 每次记录 Source、Transform、Sink;
- 完成后阅读对应 OWASP 防御文档。
官方项目:https://owasp.org/www-project-juice-shop/
11.2 PortSwigger Web Security Academy
Web Security Academy 会为学习者创建隔离实验实例,适合练习不同 XSS 上下文。建议先学习:
- Reflected XSS;
- Stored XSS;
- DOM-based XSS;
- HTML、属性、JavaScript 字符串等上下文差异;
- Source 到 Sink 的数据流分析。
官方课程:https://portswigger.net/web-security/cross-site-scripting
11.3 OWASP WebGoat
WebGoat 是 OWASP 的交互式教学环境,覆盖大量 Web 安全主题。它适合希望结合服务端代码理解 XSS 的开发者。
官方项目:https://owasp.org/www-project-webgoat/
不要把靶场 Payload 复制到普通网站测试。训练环境的授权不延伸到其他域名。
十二、Cloudflare Turnstile:测试环境不需要“绕过”
自动化测试经常被真实挑战干扰。Cloudflare 为此提供了公开的测试 sitekey 和 secret key,用于本地、CI 与测试环境。
12.1 官方测试 Key
| 场景 | Sitekey |
|---|---|
| 可见组件,始终通过 | 1x00000000000000000000AA |
| 可见组件,始终失败 | 2x00000000000000000000AB |
| 隐形组件,始终通过 | 1x00000000000000000000BB |
| 隐形组件,始终失败 | 2x00000000000000000000BB |
| 强制交互挑战 | 3x00000000000000000000FF |
测试 secret key:
| 场景 | Secret key |
|---|---|
| 始终验证成功 | 1x0000000000000000000000000000000AA |
| 始终验证失败 | 2x0000000000000000000000000000000AA |
| 返回 Token 已使用 | 3x0000000000000000000000000000000AA |
这些 Key 是官方公开的测试凭据,不能替代生产凭据。生产 secret key 会拒绝 Dummy Token。
12.2 前端测试页面
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Turnstile 本地测试</title>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer>
</script>
</head>
<body>
<form action="/submit" method="post">
<label>
邮箱
<input name="email" type="email" required>
</label>
<div
class="cf-turnstile"
data-sitekey="1x00000000000000000000AA">
</div>
<button type="submit">提交</button>
</form>
</body>
</html>
Turnstile 默认会创建名为 cf-turnstile-response 的隐藏字段,随表单一起提交。
12.3 服务端必须调用 Siteverify
安装 Express:
npm init -y
npm install express
server.js:
const express = require("express");
const app = express();
const port = Number(process.env.PORT || 3000);
const secretKey = process.env.TURNSTILE_SECRET_KEY;
if (!secretKey) {
throw new Error("缺少 TURNSTILE_SECRET_KEY");
}
app.use(express.urlencoded({ extended: false }));
app.use(express.static("public"));
async function verifyTurnstile(token, remoteIp) {
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: secretKey,
response: token,
remoteip: remoteIp,
}),
}
);
if (!response.ok) {
throw new Error(`Siteverify HTTP ${response.status}`);
}
return response.json();
}
app.post("/submit", async (request, response) => {
try {
const token = request.body["cf-turnstile-response"];
if (typeof token !== "string" || token.length === 0) {
return response.status(400).json({
ok: false,
error: "missing-turnstile-token",
});
}
const verification = await verifyTurnstile(
token,
request.ip
);
if (!verification.success) {
return response.status(400).json({
ok: false,
errors: verification["error-codes"] || [],
});
}
return response.json({
ok: true,
hostname: verification.hostname,
});
} catch (error) {
console.error(error);
return response.status(502).json({
ok: false,
error: "turnstile-validation-unavailable",
});
}
});
app.listen(port, () => {
console.log(`http://127.0.0.1:${port}`);
});
测试环境启动:
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA node server.js
要求 Node.js 18+,因为示例使用内置 fetch。
十三、为什么“修改前端”不等于通过 Turnstile?
前端页面里的勾选状态、CSS 遮罩、按钮禁用属性和回调函数都不应成为服务器的信任依据。
安全流程是:
浏览器完成挑战
↓
获得一次性 Token
↓
表单把 Token 交给你的服务器
↓
服务器携带 Secret 调用 Cloudflare Siteverify
↓
服务器根据 success、hostname、action 等结果决定是否处理业务
如果后端只接收一个 verified=true,或者因为前端回调被调用就直接放行业务,那么问题是应用没有完成服务端验证,而不是 Turnstile 被“破解”。
Cloudflare 官方说明:
- 服务端 Siteverify 是强制步骤;
- Token 有效期为 300 秒;
- Token 只能使用一次;
- 重复或过期 Token 会返回
timeout-or-duplicate; - 生产 secret key 不接受测试 Dummy Token。
十四、Playwright / Cypress 的合规测试方式
E2E 测试应在测试环境中注入官方测试 Key,而不是在生产页面伪装浏览器指纹。
环境配置:
# .env.test
TURNSTILE_SITEKEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
生产环境:
# .env.production
TURNSTILE_SITEKEY=由部署平台注入
TURNSTILE_SECRET_KEY=由密钥管理服务注入
Playwright 测试示例:
import { test, expect } from "@playwright/test";
test("测试环境中的表单能够提交", async ({ page }) => {
await page.goto("http://127.0.0.1:3000");
await page.getByLabel("邮箱").fill("developer@example.test");
await page.getByRole("button", { name: "提交" }).click();
await expect(page.getByText(/ok|提交成功/i)).toBeVisible();
});
建议在 CI 中增加保护:
const production = process.env.NODE_ENV === "production";
const sitekey = process.env.TURNSTILE_SITEKEY || "";
if (production && sitekey.startsWith("1x000000")) {
throw new Error("生产环境禁止使用 Turnstile 测试 Key");
}
十五、CSP 与 Trusted Types:建立第二道防线
修复危险数据流是第一优先级。Content Security Policy(CSP)可以降低遗漏造成的影响,但不能代替正确编码。
示例响应头:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-每次响应生成的随机值';
object-src 'none';
base-uri 'none';
frame-ancestors 'self';
require-trusted-types-for 'script';
trusted-types app-html;
要点:
- 不要为了“兼容”随意添加
'unsafe-inline'; - 避免
'unsafe-eval'; - nonce 必须每次响应随机生成;
- 先使用 Report-Only 收集兼容性问题;
require-trusted-types-for 'script'可限制部分 DOM 注入 Sink 接受普通字符串。
Trusted Types 与 DOMPurify 示例:
import DOMPurify from "dompurify";
const policy = trustedTypes.createPolicy("app-html", {
createHTML(input) {
return DOMPurify.sanitize(input);
},
});
const trustedHtml = policy.createHTML(untrustedHtml);
container.innerHTML = trustedHtml;
Trusted Types 的价值在于集中和审计转换策略。策略本身仍必须真正执行安全清洗。
十六、代码审查时如何寻找 JavaScript 注入?
16.1 搜索危险 Sink
rg -n "innerHTML|outerHTML|insertAdjacentHTML|document\.write|eval\(|new Function|srcdoc|setAttribute\(['\"]on" src
搜索结果不是漏洞结论,而是审查起点。
16.2 反向追踪数据
对每个 Sink 询问:
- 右侧数据来自哪里?
- 是否可被 URL、表单、接口或消息控制?
- 中途发生了哪些解码和拼接?
- Sanitizer 是否在最后一次拼接之后运行?
- 输出上下文是 HTML、属性、URL 还是 JavaScript?
- 是否能改为
textContent或 DOM API?
16.3 自动化工具不能替代人工判断
静态分析、浏览器 DevTools、代理工具和扫描器可以发现线索,但上下文、业务逻辑、权限和数据生命周期仍需人工分析。
十七、安全测试报告应该记录什么?
即使是在自己的系统中测试,也应留下清晰记录:
- 明确的授权范围;
- 测试时间窗口;
- 域名、路径与账号;
- Source、Transform、Sink;
- 最小化且无害的 PoC;
- 是否接触真实用户数据;
- 影响分析;
- 修复建议;
- 修复后的回归结果;
- 截图和日志中的敏感信息处理方式。
不要为了“证明影响”去读取真实 Cookie、冒充用户、发送消息或导出数据。能够用本地状态变化证明执行,就不应扩大影响。
十八、生产项目防护清单
输出与渲染
- 纯文本统一使用
textContent - 不可信数据不进入
eval、Function - 富文本使用维护良好的 Sanitizer
- 清洗后不再拼接未清洗字符串
- URL 使用解析后的协议与 Origin 白名单
-
postMessage检查event.origin和数据 Schema
浏览器安全策略
- 部署严格 CSP
- 逐步启用 Trusted Types
- 第三方脚本保持最小化
- 不使用宽泛的
unsafe-inline和unsafe-eval - 监控 CSP 违规报告
Turnstile
- 服务端强制调用 Siteverify
- 校验
success,并按业务检查hostname、action - 处理过期和重复 Token
- 测试与生产 Key 完全隔离
- CI 阻止测试 Key 进入生产
- Secret 只存在于服务器端或密钥管理系统
测试边界
- 只测试自有、获授权系统或官方靶场
- 不扫描随机站点
- 不规避 WAF、验证码或反自动化策略
- 不读取、保存或传输第三方数据
十九、总结
JavaScript 注入的核心不是某一条特殊语句,而是一条不安全的数据流:
攻击者可控数据 → 缺少正确处理 → 危险 Sink → 被浏览器当成代码
真正可靠的修复顺序是:
- 尽量不进入危险 Sink;
- 纯文本使用
textContent; - 富文本进行上下文正确的清洗;
- URL、消息和结构化数据使用白名单与 Schema;
- 使用 CSP 和 Trusted Types 降低遗漏风险;
- 在官方靶场中训练;
- Turnstile 自动化测试使用官方 Dummy Key;
- 生产环境始终进行服务端 Siteverify。
防御能力来自对数据流和信任边界的理解,而不是收集更多绕过技巧。
参考资料
- OWASP Juice Shop:https://owasp.org/www-project-juice-shop/
- OWASP WebGoat:https://owasp.org/www-project-webgoat/
- OWASP XSS Prevention Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- OWASP DOM-based XSS Prevention Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html
- PortSwigger Web Security Academy — XSS:https://portswigger.net/web-security/cross-site-scripting
- MDN Content Security Policy:https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP
- MDN Trusted Types API:https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API
- Cloudflare Turnstile Testing:https://developers.cloudflare.com/turnstile/troubleshooting/testing/
- Cloudflare Turnstile Server-side Validation:https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
文档与浏览器能力会持续变化。将示例用于实际项目之前,请再次核对官方文档,并在隔离测试环境中完成回归验证。

505

被折叠的 条评论
为什么被折叠?



