JavaScript 注入与 XSS 从原理到防御:本地靶场与 Cloudflare Turnstile 合规测试

本文面向前端、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浏览器端脚本把不可信数据传入危险 SinklocationpostMessage

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(...)解析不可信 HTMLcreateElement + 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);

createElementtextContentappendreplaceChildren 能让“数据”和“代码结构”保持分离。


七、业务确实需要富文本怎么办?

有些应用必须支持有限的 HTML,例如文章正文、Markdown 预览或富文本评论。这时不能简单地“删除 script 标签”,因为危险内容还可能藏在:

  • 事件属性,如 onerror
  • 危险 URL Scheme;
  • SVG、MathML 等上下文;
  • iframesrcdoc
  • 浏览器解析差异;
  • 编码、解码和二次渲染过程。

推荐使用持续维护的 HTML Sanitizer。以 DOMPurify 为例:

import DOMPurify from "dompurify";

const cleanHtml = DOMPurify.sanitize(untrustedHtml, {
  USE_PROFILES: { html: true },
});

container.innerHTML = cleanHtml;

注意:

  1. Sanitizer 必须保持更新;
  2. 清洗后不要再次拼接未清洗内容;
  3. 配置允许的标签和属性应遵循最小化原则;
  4. 后端存储、导出、邮件模板等不同上下文仍需单独处理;
  5. 富文本之外的字段继续使用 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));

验证时应检查解析后的 originprotocol,不要只用字符串 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”代码练习。

推荐学习方式:

  1. 从 OWASP 官方项目页进入;
  2. 优先在本地运行,或使用官方明确提供的演示环境;
  3. 开启教程模式;
  4. 只完成分配给你的挑战;
  5. 每次记录 Source、Transform、Sink;
  6. 完成后阅读对应 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 询问:

  1. 右侧数据来自哪里?
  2. 是否可被 URL、表单、接口或消息控制?
  3. 中途发生了哪些解码和拼接?
  4. Sanitizer 是否在最后一次拼接之后运行?
  5. 输出上下文是 HTML、属性、URL 还是 JavaScript?
  6. 是否能改为 textContent 或 DOM API?

16.3 自动化工具不能替代人工判断

静态分析、浏览器 DevTools、代理工具和扫描器可以发现线索,但上下文、业务逻辑、权限和数据生命周期仍需人工分析。


十七、安全测试报告应该记录什么?

即使是在自己的系统中测试,也应留下清晰记录:

  • 明确的授权范围;
  • 测试时间窗口;
  • 域名、路径与账号;
  • Source、Transform、Sink;
  • 最小化且无害的 PoC;
  • 是否接触真实用户数据;
  • 影响分析;
  • 修复建议;
  • 修复后的回归结果;
  • 截图和日志中的敏感信息处理方式。

不要为了“证明影响”去读取真实 Cookie、冒充用户、发送消息或导出数据。能够用本地状态变化证明执行,就不应扩大影响。


十八、生产项目防护清单

输出与渲染

  • 纯文本统一使用 textContent
  • 不可信数据不进入 evalFunction
  • 富文本使用维护良好的 Sanitizer
  • 清洗后不再拼接未清洗字符串
  • URL 使用解析后的协议与 Origin 白名单
  • postMessage 检查 event.origin 和数据 Schema

浏览器安全策略

  • 部署严格 CSP
  • 逐步启用 Trusted Types
  • 第三方脚本保持最小化
  • 不使用宽泛的 unsafe-inlineunsafe-eval
  • 监控 CSP 违规报告

Turnstile

  • 服务端强制调用 Siteverify
  • 校验 success,并按业务检查 hostnameaction
  • 处理过期和重复 Token
  • 测试与生产 Key 完全隔离
  • CI 阻止测试 Key 进入生产
  • Secret 只存在于服务器端或密钥管理系统

测试边界

  • 只测试自有、获授权系统或官方靶场
  • 不扫描随机站点
  • 不规避 WAF、验证码或反自动化策略
  • 不读取、保存或传输第三方数据

十九、总结

JavaScript 注入的核心不是某一条特殊语句,而是一条不安全的数据流:

攻击者可控数据 → 缺少正确处理 → 危险 Sink → 被浏览器当成代码

真正可靠的修复顺序是:

  1. 尽量不进入危险 Sink;
  2. 纯文本使用 textContent
  3. 富文本进行上下文正确的清洗;
  4. URL、消息和结构化数据使用白名单与 Schema;
  5. 使用 CSP 和 Trusted Types 降低遗漏风险;
  6. 在官方靶场中训练;
  7. Turnstile 自动化测试使用官方 Dummy Key;
  8. 生产环境始终进行服务端 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/

文档与浏览器能力会持续变化。将示例用于实际项目之前,请再次核对官方文档,并在隔离测试环境中完成回归验证。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值