Files
2026-07-13 21:36:47 +08:00

25 KiB
Raw Permalink Blame History

安全测试

何时使用:验证应用程序对常见 Web 漏洞的防御能力——XSS、CSRF、不安全的 Cookie、缺失的安全头、认证绕过以及敏感数据泄露。Playwright 不能替代专业的安全扫描工具,但它能在 E2E 测试套件中捕获最常见的问题。 前置条件core/assertions-and-waiting.mdcore/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。

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 断言 httpOnlysecuresameSite 标志 高——确定性检查
缺失安全响应头 断言响应头存在且值正确 高——确定性检查
认证绕过 未认证状态下访问受保护路由 高——测试重定向/拦截机制
会话固定攻击 比较登录前后的会话 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 通过 HTTPlocalhost)进行测试 针对 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'))

相关文档