25 KiB
安全测试
何时使用:验证应用程序对常见 Web 漏洞的防御能力——XSS、CSRF、不安全的 Cookie、缺失的安全头、认证绕过以及敏感数据泄露。Playwright 不能替代专业的安全扫描工具,但它能在 E2E 测试套件中捕获最常见的问题。 前置条件:core/assertions-and-waiting.md、core/authentication.md
快速参考
// 每次导航时检查安全响应头
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
import { test, expect } from "@playwright/test"
const XSS_PAYLOADS = [
'<script>alert("xss")</script>',
'<img src=x onerror=alert("xss")>',
'"><script>alert("xss")</script>',
"javascript:alert('xss')",
'<svg onload=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("<script")
expect(innerHTML).not.toContain("onerror")
expect(innerHTML).not.toContain("onload")
// 不应弹出对话框(脚本执行)
// 如果弹出了对话框,测试会因未处理而失败
})
}
})
test("通过 URL 参数的 XSS 被阻止", async ({ page }) => {
const xssUrl = '/search?q=<script>alert("xss")</script>'
await page.goto(xssUrl)
// 搜索词应以文本形式显示
const searchInput = page.getByRole("textbox", { name: "Search" })
const value = await searchInput.inputValue()
expect(value).not.toContain("<script")
// 页面不应包含注入的 script 标签
const scriptCount = await page.locator("script:not([src])").count()
const pageContent = await page.content()
expect(pageContent).not.toContain('alert("xss")')
})
JavaScript
const { test, expect } = require("@playwright/test")
const XSS_PAYLOADS = [
'<script>alert("xss")</script>',
'<img src=x onerror=alert("xss")>',
'"><script>alert("xss")</script>',
'<svg onload=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("<script")
expect(innerHTML).not.toContain("onerror")
})
}
})
CSRF Token 验证
何时使用:确保更改状态的请求包含有效的 CSRF token,且服务器会拒绝不带 CSRF token 的请求。 何时避免:你的 API 使用基于 token 的认证(JWT)且没有基于 Cookie 的会话——CSRF 不适用。
TypeScript
import { test, expect } from "@playwright/test"
test("表单提交包含 CSRF token", async ({ page }) => {
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
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
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
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
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
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
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
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
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<string, string> = {}
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
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
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
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——登录流程与认证状态管理
- core/network-mocking.md——拦截请求用于安全测试
- core/configuration.md——
ignoreHTTPSErrors与基础 URL 配置 - core/third-party-integrations.md——测试 OAuth 和第三方认证提供方
- ci/ci-github-actions.md——在 CI 流水线中运行安全测试