安全响应头与 CSP
next.config.ts 的 headers() 把五个安全头只挂到 /security/*:本页客户端 HEAD 请求读出自己的响应头当活证据;CSP 的演示级 'unsafe-inline' 正是「全站要 nonce」的反面教材
活证据:本页自己的响应头
客户端对本页发 HEAD 请求读回来的真实值;Server Component 的 headers() 读的是请求头,看不到响应头
正在对本页发 HEAD 请求…
curl 验证指引
dev 与生产构建都生效;响应头由配置层附加,与页面是静态还是动态无关
# 安全头只出现在 /security/* 路径: curl -I http://localhost:3000/security/headers # HTTP/1.1 200 OK # content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' ... # x-content-type-options: nosniff # x-frame-options: DENY # referrer-policy: strict-origin-when-cross-origin # permissions-policy: camera=(), microphone=(), geolocation=() # 对照组:其他路径没有这组头(刻意收敛,原因见「为什么不是全站 CSP」) curl -I http://localhost:3000/ # HTTP/1.1 200 OK # (没有上面五个头)
每个头防什么
值直接渲染自 lib/security-headers.ts 的单一数据源,改配置即改本表
| 响应头 | 本站值 | 防什么 |
|---|---|---|
| X-Content-Type-Options | nosniff | 禁止浏览器 MIME 嗅探:把「伪装成图片的脚本」当脚本执行的通道被关掉 |
| X-Frame-Options | DENY | DENY 禁止本页被任何 <iframe> 嵌入,防点击劫持;现代等价物是 CSP 的 frame-ancestors(本页两者都发) |
| Referrer-Policy | strict-origin-when-cross-origin | strict-origin-when-cross-origin:跨源跳转时 Referer 只带源不带路径,URL 里的 token/ID 不泄漏给第三方 |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | 按源关掉用不到的浏览器能力(摄像头/麦克风/定位),缩小 XSS 得手后的可利用面 |
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://images.unsplash.com; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none' | 资源加载白名单:脚本/样式/图片/连接各自圈定来源,注入的站外脚本直接不加载 |
// next.config.ts
import { SECURITY_HEADERS } from "./lib/security-headers";
const nextConfig: NextConfig = {
async headers() {
return [
{
// 只挂 /security/*:全站 CSP 需要 nonce,静态配置给不了
source: "/security/:path*",
headers: SECURITY_HEADERS,
},
];
},
};为什么不是全站 CSP:nonce vs hash vs 'unsafe-inline'
本页 CSP 里 script-src 带 'unsafe-inline' 是刻意的反面教材
- 'unsafe-inline'(本站演示):任何内联 <script> 都能跑 —— XSS 注入的脚本也能跑, script-src 防线形同虚设。它能进演示,是因为 App Router 的 RSC 载荷(self.__next_f.push)和根 layout 的防闪烁脚本都是内联的, 静态 headers() 配置无法给它们逐枚授权
- hash(sha256-...):按内容哈希放行固定脚本, 适合「内容永远不变」的内联块;但 RSC 载荷每页每请求都不同, 哈希列不完 —— 对 App Router 不可行
- nonce(正解):每请求生成一枚随机数, 同时写进 CSP 头和每个受信 <script> 标签; 攻击者猜不到本次请求的 nonce,注入脚本全部不执行。 配合 'strict-dynamic',受信脚本动态加载的依赖脚本也被信任
- 为什么推荐 proxy.ts 做这件事:nonce 必须逐请求生成, next.config.ts 的 headers() 是启动时算死的静态配置,给不了; proxy.ts(原 middleware,Next 16 起固定 Node 运行时)在请求管线上, 是唯一能在「响应头发出去之前」注入逐请求值的位置
- 代价要知道:走 proxy.ts 注入 nonce 的页面必须动态渲染 (nonce 是请求级数据),与静态预渲染/Full Route Cache 互斥 —— 这也是本站只给 /security/* 挂演示 CSP 的另一层原因
// proxy.ts —— Next 16 的中间件新约定,固定 Node 运行时
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
// 每个请求一枚随机数,写进 CSP 头,也经请求头传给渲染层
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce); // 给 layout 里的 <Script nonce> 用
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp); // 给浏览器
return response;
}
// layout.tsx 里:const nonce = (await headers()).get("x-nonce");
// <Script src="..." nonce={nonce} /> —— 带 nonce 的内联/外链脚本才被放行落地顺序建议
演示站只覆盖前两步;生产项目走到第三步
- 低风险头全站挂:X-Content-Type-Options、 Referrer-Policy、Permissions-Policy 没有副作用,直接在 headers() 里全站下发(本站为教学对照刻意只挂 /security/*)
- frame-ancestors / X-Frame-Options 全站挂: 除非产品明确要被第三方 iframe 嵌入
- CSP 先 Report-Only 再强制:先挂 Content-Security-Policy-Report-Only 收集违例报告, 确认白名单无遗漏后,用 proxy.ts + nonce 切到强制模式