# 安全测试 > **何时使用**:验证应用程序对常见 Web 漏洞的防御能力——XSS、CSRF、不安全的 Cookie、缺失的安全头、认证绕过以及敏感数据泄露。Playwright 不能替代专业的安全扫描工具,但它能在 E2E 测试套件中捕获最常见的问题。 > **前置条件**:[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/authentication.md](authentication.md) ## 快速参考 ```typescript // 每次导航时检查安全响应头 const response = await page.goto("/dashboard") expect(response.headers()["content-security-policy"]).toBeDefined() expect(response.headers()["x-frame-options"]).toBe("DENY") // 验证 Cookie 安全标志 const cookies = await context.cookies() const sessionCookie = cookies.find((c) => c.name === "session") expect(sessionCookie.httpOnly).toBe(true) expect(sessionCookie.secure).toBe(true) expect(sessionCookie.sameSite).toBe("Strict") ``` ## 模式 ### XSS 注入测试 **何时使用**:验证用户输入已被正确转义并以文本而非 HTML 形式渲染。 **何时避免**:需要进行全面的 XSS 扫描——应配合使用 OWASP ZAP 等专用工具,而非仅依赖 Playwright。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" const XSS_PAYLOADS = [ '', '', '">', "javascript:alert('xss')", '', '{{constructor.constructor("alert(1)")()}}', // 模板注入 ] test.describe("XSS 防护", () => { for (const payload of XSS_PAYLOADS) { test(`输入转义:${payload.slice(0, 40)}...`, async ({ page }) => { await page.goto("/profile/edit") // 将 payload 注入到文本字段中 await page.getByLabel("Display name").fill(payload) await page.getByRole("button", { name: "Save" }).click() // 验证 payload 以文本形式渲染,而非执行 await page.goto("/profile") const displayName = page.getByTestId("display-name") await expect(displayName).toBeVisible() // payload 文本应原样显示,而非作为 HTML const innerHTML = await displayName.innerHTML() expect(innerHTML).not.toContain(" { const xssUrl = '/search?q=' await page.goto(xssUrl) // 搜索词应以文本形式显示 const searchInput = page.getByRole("textbox", { name: "Search" }) const value = await searchInput.inputValue() expect(value).not.toContain("alert("xss")', '', '">', '', ] test.describe("XSS 防护", () => { for (const payload of XSS_PAYLOADS) { test(`输入转义:${payload.slice(0, 40)}...`, async ({ page }) => { await page.goto("/profile/edit") await page.getByLabel("Display name").fill(payload) await page.getByRole("button", { name: "Save" }).click() await page.goto("/profile") const innerHTML = await page.getByTestId("display-name").innerHTML() expect(innerHTML).not.toContain(" { await page.goto("/settings") // 验证表单中存在 CSRF token const csrfInput = page.locator('input[name="_csrf"], input[name="csrf_token"]') await expect(csrfInput).toBeAttached() const tokenValue = await csrfInput.inputValue() expect(tokenValue).toBeTruthy() expect(tokenValue.length).toBeGreaterThan(16) }) test("服务器拒绝不带 CSRF token 的请求", async ({ page, request }) => { // 先通过 UI 登录获取有效会话 await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 尝试发送不带 CSRF token 的更改状态请求 const cookies = await page.context().cookies() const response = await request.post("/api/settings", { headers: { Cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; "), }, data: { theme: "dark" }, // 没有 CSRF token }) // 服务器应拒绝该请求 expect(response.status()).toBe(403) }) test("CSRF token 每个会话轮换", async ({ browser }) => { const context1 = await browser.newContext() const context2 = await browser.newContext() const page1 = await context1.newPage() const page2 = await context2.newPage() await page1.goto("/login") await page2.goto("/login") const token1 = await page1.locator('input[name="_csrf"]').inputValue() const token2 = await page2.locator('input[name="_csrf"]').inputValue() // 不同会话的 token 应不同 expect(token1).not.toBe(token2) await context1.close() await context2.close() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("表单提交包含 CSRF token", async ({ page }) => { await page.goto("/settings") const csrfInput = page.locator('input[name="_csrf"], input[name="csrf_token"]') await expect(csrfInput).toBeAttached() const tokenValue = await csrfInput.inputValue() expect(tokenValue).toBeTruthy() expect(tokenValue.length).toBeGreaterThan(16) }) test("服务器拒绝不带 CSRF token 的请求", async ({ page, request }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") const cookies = await page.context().cookies() const response = await request.post("/api/settings", { headers: { Cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; "), }, data: { theme: "dark" }, }) expect(response.status()).toBe(403) }) ``` ### CSP 响应头验证 **何时使用**:验证 Content Security Policy 响应头是否存在且配置正确。 **何时避免**:CSP 由基础设施(CDN/WAF)管理,已单独测试。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("CSP 响应头配置正确", async ({ page }) => { const response = await page.goto("/") const csp = response!.headers()["content-security-policy"] expect(csp).toBeDefined() expect(csp).toContain("default-src 'self'") expect(csp).not.toContain("'unsafe-inline'") // 禁止内联脚本 expect(csp).not.toContain("'unsafe-eval'") // 禁止 eval() expect(csp).toContain("script-src") }) test("所有页面都存在安全响应头", async ({ page }) => { const pagesToCheck = ["/", "/login", "/dashboard", "/api/health"] for (const url of pagesToCheck) { const response = await page.goto(url) const headers = response!.headers() expect(headers["x-content-type-options"]).toBe("nosniff") expect(headers["x-frame-options"]).toMatch(/DENY|SAMEORIGIN/) expect(headers["strict-transport-security"]).toBeDefined() expect(headers["referrer-policy"]).toBeDefined() expect(headers["x-xss-protection"]).toBeUndefined() // 已弃用,不应设置 } }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("CSP 响应头配置正确", async ({ page }) => { const response = await page.goto("/") const csp = response.headers()["content-security-policy"] expect(csp).toBeDefined() expect(csp).toContain("default-src 'self'") expect(csp).not.toContain("'unsafe-inline'") expect(csp).not.toContain("'unsafe-eval'") }) test("所有页面都存在安全响应头", async ({ page }) => { const pagesToCheck = ["/", "/login", "/dashboard"] for (const url of pagesToCheck) { const response = await page.goto(url) const headers = response.headers() expect(headers["x-content-type-options"]).toBe("nosniff") expect(headers["x-frame-options"]).toMatch(/DENY|SAMEORIGIN/) expect(headers["strict-transport-security"]).toBeDefined() } }) ``` ### Cookie 安全标志 **何时使用**:验证会话 Cookie 和认证 Cookie 具有正确的安全属性。 **何时避免**:你的应用完全无状态,不使用 Cookie。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("会话 Cookie 具有正确的安全标志", async ({ page, context }) => { // 登录以创建会话 Cookie await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") const cookies = await context.cookies() // 检查会话 Cookie const session = cookies.find((c) => c.name === "session" || c.name === "sid") expect(session).toBeDefined() expect(session!.httpOnly).toBe(true) // 无法通过 JavaScript 访问 expect(session!.secure).toBe(true) // 仅通过 HTTPS 发送 expect(session!.sameSite).toBe("Strict") // 或至少为 'Lax' // 会话 Cookie 不应有过长的过期时间 if (session!.expires !== -1) { const maxAge = session!.expires - Date.now() / 1000 expect(maxAge).toBeLessThan(86400 * 30) // 不超过 30 天 } }) test("敏感 Cookie 未暴露给 JavaScript", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // document.cookie 不应包含 HttpOnly 的 Cookie const jsCookies = await page.evaluate(() => document.cookie) expect(jsCookies).not.toContain("session") expect(jsCookies).not.toContain("sid") }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("会话 Cookie 具有正确的安全标志", async ({ page, context }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") const cookies = await context.cookies() const session = cookies.find((c) => c.name === "session" || c.name === "sid") expect(session).toBeDefined() expect(session.httpOnly).toBe(true) expect(session.secure).toBe(true) expect(session.sameSite).toBe("Strict") }) ``` ### 认证绕过测试 **何时使用**:确保受保护的路由会将未认证用户重定向,且会话失效功能正常工作。 **何时避免**:认证已通过专用 API 测试覆盖了这些场景。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("未认证用户无法访问受保护路由", async ({ page }) => { const protectedRoutes = ["/dashboard", "/settings", "/admin", "/api/users"] for (const route of protectedRoutes) { const response = await page.goto(route) // 应重定向到登录页或返回 401/403 const isRedirected = page.url().includes("/login") const isBlocked = response!.status() === 401 || response!.status() === 403 expect(isRedirected || isBlocked).toBe(true) } }) test("退出登录后会话失效", async ({ page, context }) => { // 登录 await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 捕获会话 Cookie const cookiesBefore = await context.cookies() const sessionBefore = cookiesBefore.find((c) => c.name === "session") // 退出登录 await page.getByRole("button", { name: "Log out" }).click() await page.waitForURL("/login") // 验证会话 Cookie 已被清除 const cookiesAfter = await context.cookies() const sessionAfter = cookiesAfter.find((c) => c.name === "session") expect(sessionAfter).toBeUndefined() // 尝试访问受保护路由应失败 await page.goto("/dashboard") expect(page.url()).toContain("/login") }) test("过期的会话重定向到登录页", async ({ page, context }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 手动使会话 Cookie 过期 await context.clearCookies() // 下次导航应重定向到登录页 await page.goto("/dashboard") expect(page.url()).toContain("/login") }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("未认证用户无法访问受保护路由", async ({ page }) => { const protectedRoutes = ["/dashboard", "/settings", "/admin"] for (const route of protectedRoutes) { await page.goto(route) expect(page.url()).toContain("/login") } }) test("退出登录后会话失效", async ({ page, context }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") await page.getByRole("button", { name: "Log out" }).click() await page.waitForURL("/login") const cookies = await context.cookies() const session = cookies.find((c) => c.name === "session") expect(session).toBeUndefined() }) ``` ### HTTPS 重定向与敏感数据泄露 **何时使用**:验证 HTTP 请求被重定向到 HTTPS,且敏感数据不会泄露到 URL、响应头或客户端存储中。 **何时避免**:在未配置 HTTPS 的 `localhost` 上运行时。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("HTTP 重定向到 HTTPS", async ({ request }) => { // 使用 API 请求上下文来检查重定向 const response = await request.get("http://your-app.com/", { maxRedirects: 0, // 不跟随——检查重定向本身 }) expect(response.status()).toBe(301) expect(response.headers()["location"]).toMatch(/^https:\/\//) }) test("HSTS 响应头已设置", async ({ page }) => { const response = await page.goto("/") const hsts = response!.headers()["strict-transport-security"] expect(hsts).toBeDefined() expect(hsts).toContain("max-age=") // 提取 max-age 值,验证其至少为 1 年 const maxAge = parseInt(hsts!.match(/max-age=(\d+)/)?.[1] || "0") expect(maxAge).toBeGreaterThanOrEqual(31536000) }) test("敏感数据不在 URL 参数中", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 密码不应出现在 URL 中 expect(page.url()).not.toContain("password") expect(page.url()).not.toContain("token") expect(page.url()).not.toContain("secret") }) test("敏感数据不在 localStorage 中", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") const storageData = await page.evaluate(() => { const data: Record = {} for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i)! data[key] = localStorage.getItem(key)! } return JSON.stringify(data) }) expect(storageData).not.toContain("password") expect(storageData.toLowerCase()).not.toContain("secret") }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("敏感数据不在 localStorage 中", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") const storageData = await page.evaluate(() => { const data = {} for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) data[key] = localStorage.getItem(key) } return JSON.stringify(data) }) expect(storageData).not.toContain("password") expect(storageData.toLowerCase()).not.toContain("secret") }) ``` ### 会话固定攻击防护 **何时使用**:确保登录后会话 ID 发生变化,以防止会话固定攻击。 **何时避免**:使用无状态 token 认证(JWT)且没有服务器端会话。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("登录后会话 ID 发生变化", async ({ page, context }) => { await page.goto("/login") // 捕获登录前的会话标识符 const cookiesBefore = await context.cookies() const preLoginSession = cookiesBefore.find((c) => c.name === "session") const preLoginValue = preLoginSession?.value // 登录 await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 认证后会话 ID 必须改变 const cookiesAfter = await context.cookies() const postLoginSession = cookiesAfter.find((c) => c.name === "session") expect(postLoginSession).toBeDefined() if (preLoginValue) { expect(postLoginSession!.value).not.toBe(preLoginValue) } }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("登录后会话 ID 发生变化", async ({ page, context }) => { await page.goto("/login") const cookiesBefore = await context.cookies() const preLoginSession = cookiesBefore.find((c) => c.name === "session") const preLoginValue = preLoginSession?.value await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("password123") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") const cookiesAfter = await context.cookies() const postLoginSession = cookiesAfter.find((c) => c.name === "session") expect(postLoginSession).toBeDefined() if (preLoginValue) { expect(postLoginSession.value).not.toBe(preLoginValue) } }) ``` ## 决策指南 | 漏洞类型 | Playwright 测试方法 | 置信度 | | ------------------ | ----------------------------------------------------------- | ------------------------------------- | | 反射型 XSS | 在输入框和 URL 参数中注入 payload,断言无脚本执行 | 中等——覆盖常见情况,不保证穷尽 | | 存储型 XSS | 注入 payload,重新加载页面,断言输出已被转义 | 中等——捕获渲染层面的问题 | | CSRF | 验证 token 存在,测试无 token 时被拒绝 | 高——直接测试该机制 | | 不安全的 Cookie | 断言 `httpOnly`、`secure`、`sameSite` 标志 | 高——确定性检查 | | 缺失安全响应头 | 断言响应头存在且值正确 | 高——确定性检查 | | 认证绕过 | 未认证状态下访问受保护路由 | 高——测试重定向/拦截机制 | | 会话固定攻击 | 比较登录前后的会话 ID | 高——可直接验证 | | 敏感数据泄露 | 检查 URL、localStorage、响应体中是否包含机密信息 | 中等——捕获明显的泄露 | | HTTPS 强制 | 验证重定向和 HSTS 响应头 | 高——确定性检查 | ## 反模式 | 不要这样做 | 问题 | 应这样做 | | -------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- | | 仅测试登录的"快乐路径" | 遗漏了绕过向量 | 测试未认证访问、过期会话、被篡改的 token | | 只检查一个 Cookie 的 `SameSite` | 其他 Cookie 可能泄露会话信息 | 检查所有包含会话数据的 Cookie | | 忽略 API 端点的 CSP | API 在错误页面上可能返回 HTML | 同时检查 API 路由的响应头 | | 仅在开发环境中测试安全 | 开发服务器通常安全性较为宽松 | 针对与生产配置相同的预发布环境运行安全测试 | | 登录后使用 `page.waitForTimeout` | 掩盖了基于时序的认证问题 | 使用 `page.waitForURL` 或基于断言的等待 | | 在测试文件中硬编码测试凭证 | 凭证会泄露到版本控制中 | 使用环境变量或密钥管理器 | | 跳过 HTTPS 测试,因为"本地能跑" | 仅 HTTP 的本地开发掩盖了 HTTPS 问题 | 针对预发布环境测试 HTTPS 重定向,或谨慎使用 `--ignore-https-errors` | | 将 Playwright 视为完整的安全扫描工具 | Playwright 测试不是渗透测试 | 将 Playwright 用于回归检查;配合 OWASP ZAP、Burp Suite 或 Snyk 进行深入扫描 | ## 故障排查 | 症状 | 可能原因 | 修复方案 | | --------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- | | 测试中 CSP 响应头缺失,但生产环境存在 | 开发服务器未设置 CSP | 针对与生产配置相同的预发布环境运行安全测试 | | Cookie 的 `secure` 标志为 `false` | 通过 HTTP(localhost)进行测试 | 针对 HTTPS 预发布环境测试,或验证该标志在生产环境有条件设置 | | CSRF 测试不带 token 仍通过 | 测试环境中 CSRF 防护被禁用 | 在测试环境中启用 CSRF,或针对预发布环境运行 | | XSS payload 未执行但测试通过 | 框架默认自动转义 | 仍应测试——测试确认了防护有效;为原始 HTML 渲染场景添加边界测试用例 | | `context.cookies()` 返回空 | Cookie 设置在另一个域名或路径上 | 将特定 URL 传给 `context.cookies('https://your-app.com')` | | HSTS 响应头检查在 localhost 上失败 | HSTS 需要 HTTPS 和有效证书 | 跳过 localhost 的 HSTS 测试,针对预发布环境运行 | | 按名称找不到会话 Cookie | Cookie 名称在不同环境中不同 | 按模式搜索:`cookies.find(c => c.name.includes('sess'))` | ## 相关文档 - [core/authentication.md](authentication.md)——登录流程与认证状态管理 - [core/network-mocking.md](network-mocking.md)——拦截请求用于安全测试 - [core/configuration.md](configuration.md)——`ignoreHTTPSErrors` 与基础 URL 配置 - [core/third-party-integrations.md](third-party-integrations.md)——测试 OAuth 和第三方认证提供方 - [ci/ci-github-actions.md](../ci/ci-github-actions.md)——在 CI 流水线中运行安全测试