跳到主要内容
CodeAuditAgent
全部文章

安全响应头详解:CSP、HSTS 及其他

CSP 的 nonce 与 strict-dynamic、HSTS、frame-ancestors 和 COOP 实用指南,附 Next.js 配置示例。

· 阅读约 7 分钟 · Lina Source LLC

安全响应头是你的服务器给浏览器的指令:只运行这些脚本,只通过 HTTPS 与我通信,不要让其他站点把这个页面放进框架。它们不会修复你代码里的漏洞,但会限制攻击者能用漏洞做什么。一个藏在严格 CSP 之后的跨站脚本漏洞,比没有 CSP 时的同一个漏洞要小得多。

这些响应头大多只需一行配置。例外是 CSP,它需要规划。本文讲每个响应头的作用、对典型 Web 应用而言合理的取值,以及如何在不搞坏生产环境的前提下上线它们。

Content-Security-Policy

CSP 告诉浏览器哪些来源的脚本、样式、图片、框架和连接是被允许的。它的主要职责是阻止被注入的脚本运行。域名允许列表听起来是显而易见的做法,但它有长长的绕过历史:任何被允许的、托管着用户内容或旧版本库的 CDN,都可能被用来加载攻击者控制的脚本。

nonce 与 strict-dynamic

站得住脚的做法是基于 nonce 的策略。服务器为每个响应生成一个随机值,把它放进 CSP 响应头,并作为 nonce 属性加到它渲染的每一个 script 标签上。注入的 script 标签不知道 nonce,因此会被阻止。加上 'strict-dynamic' 之后,由受信脚本加载的脚本可以继续加载其他脚本,这让打包工具和标签管理器无需逐一列出域名也能正常工作。

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
  style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  upgrade-insecure-requests

该响应头是以单行发送的;这里换行只是为了方便阅读。那条策略中有几个指令的作用比看上去更大。object-src 'none' 阻止老式插件内容。base-uri 'none' 阻止被注入的 base 标签改变相对脚本 URL 的指向。form-action 'self' 防止被注入的表单把凭据提交到别处。nonce 必须不可预测且每个响应都是新的,这意味着使用它的页面不能由静态缓存提供。

用 Report-Only 模式上线

盲目上线的严格 CSP 一定会搞坏点什么:一段分析代码、一个内联事件处理器、一个第三方挂件。请先把策略以 Content-Security-Policy-Report-Only 的形式发布。浏览器不做任何拦截,但会把每一次违规上报到 report-to 或 report-uri 指令指定的端点。收集一段时间的上报,修复或放行其中合理的部分,然后把响应头名字换成强制模式的那个。强制之后也要继续保留上报,因为新出现的违规不是回归就是攻击。上报中会有来自浏览器扩展的噪声,它们会把自己的脚本注入页面;在决定放行什么之前,先按来源把它们过滤掉。

Strict-Transport-Security

HSTS 告诉浏览器在一段时间内对你的域名一律使用 HTTPS,即使用户输入 http:// 或点了一个旧链接。这就关上了网络攻击者拦截第一个明文 HTTP 请求、并剥掉跳转到 HTTPS 的那个窗口。浏览器只在该响应头通过 HTTPS 抵达时才会遵守它,而且它们不允许用户在 HSTS 域名上点击忽略证书错误,这正是其用意所在,但证书一旦过期,这也是风险。

先从较短的 max-age 开始,比如一天,确认没有问题后再提高到一到两年。includeSubDomains 会把规则扩展到每一个子域,所以请先确认它们全都提供有效的 HTTPS,包括同一域名下的旧营销站点和内部工具。

preload 指令配合向浏览器预加载列表提交申请,会把你的域名烧进浏览器,连第一次访问都走 HTTPS。它要求 max-age 至少一年并带 includeSubDomains。请把它当作一扇单向门:从列表中移除是可能的,但要很久才能覆盖到用户,而在此期间任何无法提供 HTTPS 的子域都会变得无法访问。

只需一行的响应头

  • X-Content-Type-Options: nosniff。阻止浏览器猜测与你声明的不同的内容类型,从而避免以文本形式提供的上传文件被当作脚本执行。
  • CSP 中的 frame-ancestors 以及 X-Frame-Options: DENY。控制谁可以把你的页面嵌入框架,这是防御点击劫持的手段。两者同时设置时,现代浏览器采用 frame-ancestors 并忽略 X-Frame-Options;保留 X-Frame-Options 是为了照顾较老的客户端。如果你需要嵌入自己的页面,请使用 'self' 或 SAMEORIGIN。
  • Referrer-Policy: strict-origin-when-cross-origin。只向其他站点发送你的源,而不发送完整路径和查询字符串。这能防止 URL 中的令牌和 ID 泄露给第三方。对特别敏感的页面请使用 no-referrer。
  • Permissions-Policy。关闭你用不到的浏览器功能,比如 camera=()、microphone=()、geolocation=() 和 payment=(),让被注入或被嵌入的代码无法申请它们。
  • Cross-Origin-Opener-Policy: same-origin。把你的页面放进它自己的浏览上下文组,这样由其他站点打开的窗口就无法保留对它的引用。如果你依赖 OAuth 或支付弹窗,请使用 same-origin-allow-popups。
  • Cross-Origin-Resource-Policy: same-origin 或 same-site。告诉浏览器不要让其他源把你的响应作为图片、脚本或其他子资源加载。如果你从同级子域提供静态资源,请使用 same-site;对打算被别处嵌入的资源使用 cross-origin。

你可以去掉 X-XSS-Protection。它控制的那个过滤器已经从现代浏览器中移除,CSP 是它的替代品。如果某个扫描器坚持要它,就把它设为 0。同样地,避免那些只会给攻击者提供信息的响应头:X-Powered-By 和详细的 Server 标识会白白暴露你的框架和版本。在 Next.js 中,在配置里设置 poweredByHeader: false 即可去掉前者。

一份 Next.js 配置

静态响应头属于 next.config.ts,其中的 headers() 函数会把它们应用到每一条路由。下面的取值,对于一个不把自己嵌入框架、不使用摄像头或定位、并自行提供静态资源的应用来说是合理的默认值。请按你的应用实际所做的事逐项调整,而不是照搬。

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=(), payment=()",
  },
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

export default nextConfig;

CSP 需要每个请求一个新的 nonce,因此它改在中间件里设置。Next.js 会从请求的 Content-Security-Policy 响应头读取 nonce,并在渲染时把它应用到框架自身的脚本上。你可以在服务端组件中用 headers().get("x-nonce") 读到它,用于你自己的 script 标签。

// middleware.ts(在 Next.js 16 中该文件为 proxy.ts,函数为 proxy)
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "upgrade-insecure-requests",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

使用 nonce 渲染的页面必须动态渲染,因为静态页面会让所有访问者复用同一个 nonce。在上线过程中,把两处的响应头名字都改成 Content-Security-Policy-Report-Only 并添加一个上报端点。在开发环境中,React 的调试功能可能需要在 script-src 里加上 'unsafe-eval';只在 NODE_ENV 为 development 时才加。

如何验证

  • 对首页、一个动态页面、一条 API 路由和一个静态资源分别运行 curl -sI https://your-domain.example/,并逐一查看响应头。只在 HTML 页面上设置的响应头是常见的缺口。
  • 打开浏览器开发者工具。CSP 违规会连同指令和被阻止的资源一起出现在控制台里,而网络面板会显示实际发送的响应头。
  • 除了源站,也要在 CDN 或反向代理之后做检查。代理有时会剥离、重复或覆盖响应头,而两个相互冲突的 CSP 响应头都会被强制执行,于是实际生效的策略比其中任何一个都更严格。
  • 使用 Mozilla HTTP Observatory 之类的公开检测工具快速获取第二意见,并用 Google 的 CSP Evaluator 找出薄弱的 CSP 指令。
  • 在 CI 中加一个测试,请求关键路由并断言这些响应头存在,这样配置重构就不会悄悄把它们弄丢。

响应头属于配置,而这正是它们会漂移的原因:一个自行构造响应的新路由处理器、一条覆盖默认值的代理规则、为了让某次发布通过而用 'unsafe-inline' 放宽的 CSP。请像对待代码一样评审它们。CodeAuditAgent 会把公开仓库或粘贴片段中的配置与其余代码一并读取,并报告缺失或被削弱的响应头,附带严重程度、原文引用的配置和建议修复。

一个合理的推进顺序:今天先上那些只需一行的响应头,本周用较短的 max-age 上 HSTS,然后尽快在能收集上报时以 Report-Only 模式上线基于 nonce 的 CSP。