Files
novalon-website/e2e/security-headers.spec.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

379 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from './fixtures';
/**
* 安全测试套件
*
* 覆盖范围:
* 1. 安全响应头测试 - 验证关键安全响应头存在且正确
* 2. 内容安全策略详细检查
* 3. 表单安全测试 - 验证联系表单的提交方法和输入验证
* 4. XSS 防护测试 - 验证 URL 参数中的特殊字符不导致页面崩溃
* 5. 敏感信息泄露测试 - 验证页面不泄露敏感信息
* 6. 安全传输测试 - 验证静态资源通过 HTTPS 加载
*/
test.setTimeout(60000);
// 需要测试的页面列表
const PAGES = [
{ name: '首页', path: '/' },
{ name: '联系页', path: '/contact' },
{ name: '产品页', path: '/products' },
{ name: '404页面', path: '/this-page-does-not-exist' },
];
// ==================== 1. 安全响应头测试 ====================
test.describe('安全响应头 - 关键安全头验证', { tag: '@security' }, () => {
for (const { name, path } of PAGES) {
test(`${name} (${path}) 应包含安全响应头`, async ({ request }) => {
console.log(`正在测试 ${name} (${path}) 的安全响应头...`);
const response = await request.get(path);
const headers = response.headers();
// 验证 X-Content-Type-Options: nosniff
expect(headers['x-content-type-options']).toBe('nosniff');
console.log(` ✅ X-Content-Type-Options: ${headers['x-content-type-options']}`);
// 验证 X-Frame-Options: DENY 或 SAMEORIGIN
expect(headers['x-frame-options']).toMatch(/^(DENY|SAMEORIGIN)$/);
console.log(` ✅ X-Frame-Options: ${headers['x-frame-options']}`);
// 验证 X-XSS-Protection: 1; mode=block
expect(headers['x-xss-protection']).toBe('1; mode=block');
console.log(` ✅ X-XSS-Protection: ${headers['x-xss-protection']}`);
// 验证 Referrer-Policy 存在
expect(headers['referrer-policy']).toBeTruthy();
console.log(` ✅ Referrer-Policy: ${headers['referrer-policy']}`);
// 验证 Content-Security-Policy 存在
expect(headers['content-security-policy']).toBeTruthy();
console.log(` ✅ Content-Security-Policy 存在`);
// 验证 Permissions-Policy 存在
expect(headers['permissions-policy']).toBeTruthy();
console.log(` ✅ Permissions-Policy: ${headers['permissions-policy']}`);
// 验证 X-Powered-By 不存在(避免泄露技术栈信息)
expect(headers['x-powered-by']).toBeUndefined();
console.log(` ✅ X-Powered-By 未泄露`);
});
}
});
// ==================== 2. CSP 详细内容验证 ====================
test.describe('内容安全策略 - CSP 详细检查', { tag: '@security' }, () => {
test('CSP 应包含关键安全指令', async ({ request }) => {
const response = await request.get('/');
const csp = response.headers()['content-security-policy'];
expect(csp).toBeTruthy();
// 验证关键安全指令存在
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("object-src 'none'");
expect(csp).toContain("base-uri 'self'");
expect(csp).toContain("form-action 'self'");
console.log(' ✅ CSP 包含所有关键安全指令');
});
test('CSP 应限制外部资源加载', async ({ request }) => {
const response = await request.get('/');
const csp = response.headers()['content-security-policy'];
expect(csp).toBeTruthy();
// script-src 应限制可执行的脚本来源
expect(csp).toContain("script-src 'self'");
// img-src 应限制图片加载来源
expect(csp).toContain('img-src');
// font-src 应限制字体来源
expect(csp).toContain("font-src 'self'");
// connect-src 应限制连接来源
expect(csp).toContain('connect-src');
console.log(' ✅ CSP 正确限制外部资源加载来源');
});
});
// ==================== 3. 表单安全测试 ====================
test.describe('表单安全 - 联系表单验证', { tag: '@security' }, () => {
test.beforeEach(async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
});
test('表单提交应使用 POST 方法', async ({ page }) => {
// 验证表单元素存在
const form = page.locator('form').first();
await expect(form).toBeVisible();
// 验证表单 method 属性为 post(或 form 的 action 属性指向 API 端点)
const formMethod = await form.getAttribute('method');
const formAction = await form.getAttribute('action');
console.log(` Form method: ${formMethod}, action: ${formAction}`);
// N-27 修复:原实现把「拦截成功」与「5s 超时」并列为两条通过路径
//(超时分支只查字段可见性)⇒ 表单不再 POST /api/contact 也绿。
// 真实契约:contact-content-v3.tsx:181-182 校验通过后恒定
// fetch('/api/contact', { method: 'POST' })。本例填入的全是合法值
//(zod 校验可通过),因此拦截必须发生且方法必须是 POST,否则变红。
let capturedMethod = '';
await page.route('**/api/contact', async (route) => {
capturedMethod = route.request().method();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
// 填充表单并提交
await page.locator('[data-testid="name-input"]').fill('测试用户');
await page.locator('[data-testid="phone-input"]').fill('13800138000');
await page.locator('[data-testid="email-input"]').fill('test@example.com');
await page.locator('[data-testid="subject-input"]').fill('安全测试');
await page.locator('[data-testid="message-input"]').fill('这是一条安全测试消息,用于验证表单提交方法。');
// 隐藏可能覆盖底部元素的固定返回顶部按钮
await page.evaluate(() => {
const backToTop = document.querySelector('[aria-label="返回顶部"]') as HTMLElement | null;
if (backToTop) backToTop.style.display = 'none';
});
// 提交表单
await page.locator('[data-testid="submit-button"]').click();
// 等待拦截发生(10s 内未发生 ⇒ 红),随后硬断言方法为 POST
await expect
.poll(() => capturedMethod, {
timeout: 10000,
message: '提交后 10s 内未拦截到 /api/contact 请求(表单不再向该端点提交?)',
})
.not.toBe('');
expect(capturedMethod).toBe('POST');
console.log(' ✅ 表单提交使用 POST 方法');
});
test('输入字段包含验证属性', async ({ page }) => {
// 检查姓名输入框
const nameInput = page.locator('[data-testid="name-input"]');
await expect(nameInput).toBeVisible();
const nameRequired = await nameInput.getAttribute('required');
expect(nameRequired).not.toBeNull();
console.log(' ✅ 姓名字段有 required 属性');
// 检查电话输入框
const phoneInput = page.locator('[data-testid="phone-input"]');
await expect(phoneInput).toBeVisible();
const phoneType = await phoneInput.getAttribute('type');
expect(phoneType).toBe('tel');
const phoneRequired = await phoneInput.getAttribute('required');
expect(phoneRequired).not.toBeNull();
console.log(' ✅ 电话字段有 type="tel" 和 required 属性');
// 检查邮箱输入框
const emailInput = page.locator('[data-testid="email-input"]');
await expect(emailInput).toBeVisible();
const emailType = await emailInput.getAttribute('type');
expect(emailType).toBe('email');
const emailRequired = await emailInput.getAttribute('required');
expect(emailRequired).not.toBeNull();
console.log(' ✅ 邮箱字段有 type="email" 和 required 属性');
// 检查主题输入框
const subjectInput = page.locator('[data-testid="subject-input"]');
await expect(subjectInput).toBeVisible();
const subjectRequired = await subjectInput.getAttribute('required');
expect(subjectRequired).not.toBeNull();
console.log(' ✅ 主题字段有 required 属性');
// 检查留言输入框
const messageInput = page.locator('[data-testid="message-input"]');
await expect(messageInput).toBeVisible();
const messageRequired = await messageInput.getAttribute('required');
expect(messageRequired).not.toBeNull();
console.log(' ✅ 留言字段有 required 属性');
});
test('提交按钮存在且可点击', async ({ page }) => {
const submitButton = page.locator('[data-testid="submit-button"]');
await expect(submitButton).toBeVisible();
await expect(submitButton).toBeEnabled();
const buttonText = await submitButton.textContent();
expect(buttonText).toBeTruthy();
expect(buttonText!.trim().length).toBeGreaterThan(0);
console.log(` ✅ 提交按钮存在且可点击,文本: ${buttonText!.trim()}`);
});
});
// ==================== 4. XSS 防护测试 ====================
test.describe('XSS 防护 - 特殊字符处理', { tag: '@security' }, () => {
const xssPayloads = [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'"><script>alert(1)</script>',
"'; alert(1); '",
];
for (const payload of xssPayloads) {
test(`首页 URL 参数包含 XSS payload 不应导致页面崩溃: "${payload.slice(0, 25)}..."`, async ({ page }) => {
console.log(`正在测试 XSS payload: ${payload}`);
const response = await page.goto(`/?q=${encodeURIComponent(payload)}`, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// 验证页面正常响应
expect(response?.status()).toBe(200);
console.log(` ✅ 页面状态码: ${response?.status()}`);
// 验证页面标题存在且不为空
const title = await page.title();
expect(title).toBeTruthy();
console.log(` ✅ 页面标题: ${title}`);
// 验证页面 body 内容正常(未崩溃)
const bodyContent = await page.evaluate(() => document.body?.textContent?.length || 0);
expect(bodyContent).toBeGreaterThan(0);
console.log(` ✅ 页面 body 内容正常 (${bodyContent} 字符)`);
});
}
test('产品页面 URL 参数 XSS 防护', async ({ page }) => {
const payload = '<script>alert("xss")</script>';
console.log(`正在测试产品页 XSS payload: ${payload}`);
const response = await page.goto(`/products?q=${encodeURIComponent(payload)}`, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// 验证页面正常响应
expect(response?.status()).toBe(200);
const title = await page.title();
expect(title).toBeTruthy();
console.log(` ✅ 产品页 XSS 测试通过,页面标题: ${title}`);
// 验证页面未崩溃
const bodyContent = await page.evaluate(() => document.body?.textContent?.length || 0);
expect(bodyContent).toBeGreaterThan(0);
console.log(` ✅ 产品页 body 内容正常 (${bodyContent} 字符)`);
});
});
// ==================== 5. 敏感信息泄露测试 ====================
test.describe('敏感信息泄露 - 页面内容检查', { tag: '@security' }, () => {
test('联系页面不直接显示电话号码', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 获取页面可见文本内容(排除输入框中的值)
const pageText = await page.evaluate(() => {
// 克隆 body 并移除所有 input 元素,避免误判输入框中的占位符
const clone = document.body?.cloneNode(true) as HTMLElement;
if (clone) {
clone.querySelectorAll('input, textarea').forEach(el => el.remove());
}
return clone?.textContent || '';
});
// 手机号模式匹配
const phonePattern = /1[3-9]\d{9}/;
const hasPhoneNumber = phonePattern.test(pageText);
// 联系页面不应直接显示手机号码
expect(hasPhoneNumber).toBe(false);
console.log(' ✅ 联系页面未直接显示电话号码');
});
test('404 页面不显示堆栈跟踪', async ({ page }) => {
// 在 Next.js 开发模式下,404 页面可能返回 200 状态码(因为页面被渲染为有效的路由)
// 我们的目标是验证页面内容不泄露堆栈跟踪信息
const response = await page.goto('/non-existent-route-for-testing', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// 记录状态码(开发模式可能为 200,生产模式为 404)
console.log(` 404 页面状态码: ${response?.status()}`);
// 获取页面可见文本内容(排除 RSC 内部负载中可能包含的 "Error:" 等关键词)
const pageText = await page.evaluate(() => {
// 获取可见元素内容,排除 <script> 和隐藏元素
const visibleElements = document.querySelectorAll('body *:not(script):not([style*="display:none"]):not([style*="display: none"])');
return Array.from(visibleElements)
.map(el => (el as HTMLElement).textContent || '')
.filter(t => t.trim().length > 0)
.join(' ');
});
// 检查是否包含堆栈跟踪相关关键词(使用更精确的匹配模式)
const stackTraceIndicators = [
'TypeError:',
'ReferenceError:',
'SyntaxError:',
'stack trace',
'node_modules',
];
// 对于 "Error:" 关键词,检查是否以堆栈跟踪格式出现(如 "Error: Cannot read")
const errorPattern = /Error:\s/;
for (const indicator of stackTraceIndicators) {
expect(pageText).not.toContain(indicator);
}
// 检查 "Error:" 是否以堆栈跟踪格式出现(而非正常文本中的 "错误")
if (errorPattern.test(pageText)) {
// 如果发现 "Error: " 格式,记录警告但作为软验证
console.log(' ⚠️ 页面文本包含 "Error:" 模式,但可能为正常渲染内容');
}
console.log(' ✅ 404 页面未泄露堆栈跟踪信息');
// 验证页面包含友好的用户提示
const heading = page.locator('h1').first();
await expect(heading).toBeVisible();
const headingText = await heading.textContent();
expect(headingText).toBeTruthy();
console.log(` ✅ 404 页面显示友好提示: ${headingText}`);
});
test('响应头不泄露服务器版本信息', async ({ request }) => {
const response = await request.get('/non-existent-route-for-testing');
const headers = response.headers();
// 检查是否泄露服务器版本信息
expect(headers['x-powered-by']).toBeUndefined();
console.log(' ✅ 响应头未泄露 X-Powered-By 信息');
});
});
// ==================== 6. 安全传输测试 ====================
test.describe('安全传输 - 资源加载', { tag: '@security' }, () => {
test('静态资源通过 HTTPS 加载', async ({ page, baseURL }) => {
// 本地开发环境使用 HTTP,跳过此测试
// 生产环境(HTTPS)下应确保所有资源通过 HTTPS 加载
test.skip(
!!baseURL && baseURL.startsWith('http://localhost'),
'本地 harness 恒以 http://localhost 提供站点(playwright.config.ts:27),HTTPS-only 断言只能在真实 HTTPS 部署上执行(N-17)'
);
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 检查页面中所有资源链接
const resources = await page.evaluate(() => {
const elements = document.querySelectorAll(
'link[rel="stylesheet"], script[src], img[src], source[src], video[src]'
);
return Array.from(elements)
.map((el) => {
const htmlEl = el as HTMLLinkElement | HTMLScriptElement | HTMLImageElement | HTMLSourceElement | HTMLVideoElement;
return htmlEl.src || (htmlEl as HTMLLinkElement).href;
})
.filter(Boolean);
});
const httpResources = resources.filter((r) => r.startsWith('http://'));
if (httpResources.length > 0) {
console.log(` ⚠️ 发现 ${httpResources.length} 个 HTTP 资源:`, httpResources);
}
expect(httpResources.length).toBe(0);
console.log(' ✅ 所有静态资源均通过 HTTPS 加载');
});
});