639 lines
25 KiB
Markdown
639 lines
25 KiB
Markdown
# 安全测试
|
||
|
||
> **何时使用**:验证应用程序对常见 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 = [
|
||
'<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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```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<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**
|
||
|
||
```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 流水线中运行安全测试
|