安全响应头与 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-Optionsnosniff禁止浏览器 MIME 嗅探:把「伪装成图片的脚本」当脚本执行的通道被关掉
X-Frame-OptionsDENYDENY 禁止本页被任何 <iframe> 嵌入,防点击劫持;现代等价物是 CSP 的 frame-ancestors(本页两者都发)
Referrer-Policystrict-origin-when-cross-originstrict-origin-when-cross-origin:跨源跳转时 Referer 只带源不带路径,URL 里的 token/ID 不泄漏给第三方
Permissions-Policycamera=(), microphone=(), geolocation=()按源关掉用不到的浏览器能力(摄像头/麦克风/定位),缩小 XSS 得手后的可利用面
Content-Security-Policydefault-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 的内联/外链脚本才被放行

落地顺序建议

演示站只覆盖前两步;生产项目走到第三步

  1. 低风险头全站挂:X-Content-Type-Options、 Referrer-Policy、Permissions-Policy 没有副作用,直接在 headers() 里全站下发(本站为教学对照刻意只挂 /security/*)
  2. frame-ancestors / X-Frame-Options 全站挂: 除非产品明确要被第三方 iframe 嵌入
  3. CSP 先 Report-Only 再强制:先挂 Content-Security-Policy-Report-Only 收集违例报告, 确认白名单无遗漏后,用 proxy.ts + nonce 切到强制模式