安全响应头

安全响应头把浏览器行为收束为可审查的策略;重点掌握CSP、HSTS、MIME、嵌入权限与可靠部署。

难度 进阶 时长 标准深度约14分钟
版本 Node 24
what

安全响应头是服务器随响应发送的浏览器策略,用于约束资源加载、页面嵌入、传输方式、类型解释、来源信息和敏感功能。

trap

复制一套“最严格”的头并不等于安全;宽泛CSP会失去防护价值,错误的HSTS或权限策略还可能让站点无法访问或功能失效。

fix

按响应类型建立最小策略,由一个明确的部署层负责写入,在报告模式和真实浏览器中验证后再逐步收紧。

是什么,为什么存在

安全响应头(security response header)是服务器放进HTTP响应的字段,浏览器据此执行额外的安全规则。它们不修改服务器已经做出的授权决定,也不会清理不可信输入。它们控制的是浏览器接到响应之后可以加载、执行、发送或暴露什么。

Web页面会同时处理文档、脚本、样式、图片、表单和嵌入内容。只靠HTML与JavaScript代码,很难为这些行为建立统一且不可被页面脚本轻易改写的边界。响应头让边界跟随资源到达浏览器,并能覆盖模板、静态资源和错误页面。

这是一层纵深防御。例如,应用仍需从源头阻止XSS; 内容安全策略(Content Security Policy,CSP) 可以在注入发生后限制脚本执行与数据外传。TLS仍负责连接机密性与完整性; HTTP严格传输安全(HTTP Strict Transport Security,HSTS) 则让已获知策略的浏览器不再尝试明文HTTP。

你会在应用框架、中间件、反向代理、API网关和CDN配置中遇到这些头。最终行为取决于浏览器收到的完整响应,而不是某一层打算发送的配置。因此,策略设计和部署验证必须同时覆盖成功、重定向与错误路径。

常用响应头解决不同问题,不能互相替代:

响应头或指令浏览器执行的边界不能替代
Content-Security-Policy限制资源来源、脚本执行、表单目标和页面祖先输出编码与输入校验
Strict-Transport-Security在有效期内把该主机的HTTP访问升级为HTTPSTLS证书与安全TLS配置
X-Content-Type-Options: nosniff对脚本和样式等请求拒绝不匹配的MIME类型正确的Content-Type
Referrer-Policy控制请求携带多少Referer信息避免把秘密放入URL
Permissions-Policy限制文档与嵌入内容可用的浏览器功能用户授权与操作系统权限
CSP frame-ancestors限制哪些祖先页面可以嵌入当前页面被嵌入页面自身的授权
X-Frame-Options为DENY或SAMEORIGIN提供较旧的嵌入控制CSP的多来源嵌入策略

工作原理

服务器在状态行和响应头之后发送响应主体。浏览器先解析适用于该响应的策略,再决定怎样处理主体以及后续子资源请求。不同头在不同阶段生效,所以“头已经存在”只是检查起点,值、响应类型和浏览器上下文同样重要。

CSP的决策顺序

CSP由分号分隔的指令组成。script-src、style-src、img-src和connect-src等获取指令分别约束脚本、样式、图片和网络连接。缺少专用获取指令时,许多资源类型回退到default-src;frame-ancestors、base-uri与form-action不使用这项回退,必须按需要显式设置。

来源表达式越宽,浏览器允许的内容越多。'self'表示与受保护文档同源,'none'表示不允许任何来源。仅允许https:或宽泛主机通配符,仍可能信任一个可被攻击者利用的来源;策略必须从应用真实依赖反推,不能从“允许整个互联网”开始。

默认情况下,严格的script-src会阻止内联脚本。需要保留内联脚本时,可以给本次响应生成不可预测且只使用一次的 CSP nonce ,同时把同一个值放进策略与获准的<script>元素。Nonce只证明浏览器可以执行该元素,不会替代对插入其中的数据进行安全序列化。

其他头各自维护状态

HSTS是浏览器按主机保存的有期限状态。浏览器只接受安全HTTPS响应中的HSTS头;后续访问匹配主机时,它会先把HTTP URL升级为HTTPS,再发出网络请求。includeSubDomains扩大到子域名,preload表示站点希望进入浏览器预加载机制,但单独写出这个令牌不会自动完成预加载登记。

X-Content-Type-Options: nosniff要与准确的Content-Type一起使用。它不是MIME类型探测器的替代品,而是要求浏览器在相关上下文中不要把错误类型的响应当成脚本或样式执行。上传内容还需要安全文件名、独立来源、下载语义和服务端验证。

Referrer-Policy决定导航或子资源请求中的Referer粒度。strict-origin-when-cross-origin在同源请求中保留完整URL,跨源时只发送源,并在从HTTPS降级到HTTP时不发送来源。敏感页面可以使用no-referrer,但最重要的规则仍是不要把令牌、密码或个人数据放进URL。

Permissions-Policy按功能名声明允许列表,例如camera=()禁用摄像头,geolocation=(self)只允许同源使用地理位置。它限制的是功能是否可供文档使用,实际调用仍可能触发用户授权。浏览器不认识的功能名可能被忽略,因此要针对目标浏览器测试最终行为。

策略所有权

一个响应可能经过应用、中间件、反向代理和CDN。若多层同时写同一个头,可能产生重复字段、被覆盖的指令或只在部分状态码上生效的配置。团队应指定一个最终策略所有者,并把其他层的责任写清楚,例如应用提供每次响应的nonce,边缘层统一补充静态头。

CSP的多个字段不会简单地合并为一条更宽松策略。浏览器会分别执行每条策略,结果通常更严格;然而运维人员很难从多层输出中判断真实意图。检查最终线上的响应,比只测试框架配置对象更可靠。

示例

生成一套基线策略

这个纯函数为普通HTML响应生成最小基线。示例故意没有加入includeSubDomains或preload,因为这两项必须先审计整个域名空间。真实应用还要按资源清单补充CSP来源,并为API、下载和静态资源定义不同策略。

baseline_headers.js
function buildHeaders({ https = true, embedders = [] } = {}) {
  const ancestors = embedders.length
    ? ["'self'", ...embedders].join(' ')
    : "'none'";

  const headers = {
    'Content-Security-Policy': [
      "default-src 'self'",
      "script-src 'self'",
      "object-src 'none'",
      `frame-ancestors ${ancestors}`,
      "base-uri 'self'",
      "form-action 'self'",
    ].join('; '),
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'X-Content-Type-Options': 'nosniff',
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
  };

  if (https) {
    headers['Strict-Transport-Security'] = 'max-age=31536000';
  }
  return headers;
}

for (const [name, value] of Object.entries(buildHeaders())) {
  console.log(`${name}: ${value}`);
}
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=31536000

frame-ancestors 'none'禁止任何页面嵌入当前页面;确实需要同源或合作方嵌入时,应传入经过审查的完整来源。HSTS只在https为真时生成,但部署层还必须保证它只出现在真实HTTPS响应中,并正确识别受信代理传来的协议。

为每次响应生成CSP nonce

下面的渲染函数使用Node 24的密码学随机源生成128位nonce。输出只检查安全性质,不打印随机秘密,所以重复执行仍能得到稳定、可审查的结果。示例也转义消息中的<,说明nonce与数据安全是两条独立边界。

nonce_csp.js
import { randomBytes } from 'node:crypto';

function renderPage(message) {
  const nonce = randomBytes(16).toString('base64');
  const policy = [
    "default-src 'self'",
    `script-src 'nonce-${nonce}'`,
    "object-src 'none'",
    "base-uri 'none'",
  ].join('; ');

  // nonce 授权这个元素;转义仍负责保护数据边界。
  const safeMessage = JSON.stringify(message).replaceAll('<', '\\u003c');
  const html = [
    '<!doctype html><meta charset="utf-8">',
    '<p id="status"></p>',
    `<script nonce="${nonce}">`,
    `document.querySelector('#status').textContent = ${safeMessage};`,
    '</script>',
  ].join('\n');
  return { nonce, policy, html };
}

const first = renderPage('</script><script>alert(1)</script>');
const second = renderPage('ready');
console.log('nonce bytes', Buffer.from(first.nonce, 'base64').length);
console.log('policy matches markup', first.policy.includes(`'nonce-${first.nonce}'`) && first.html.includes(`nonce="${first.nonce}"`));
console.log('changes per response', first.nonce !== second.nonce);
console.log('message escaped', first.html.includes('\\u003c/script>'));
nonce bytes 16
policy matches markup true
changes per response true
message escaped true

生产模板不能把nonce固定在构建产物、进程全局变量或可长期缓存的HTML中。若CDN缓存带nonce的页面,策略头和HTML必须属于同一个缓存对象,而且每个缓存对象的nonce不能被攻击者预测。包含用户数据的HTML通常还需要更严格的缓存规则。

审计所有响应路径

这项测试在路由判断前设置公共策略,再请求成功与未找到路径。它证明两种响应都携带目标字段,但没有声称本地HTTP服务会执行HSTS或完整浏览器策略;原始头检查之后仍需真实浏览器测试。

header_audit.js
import { createServer } from 'node:http';

const secureHeaders = {
  'Content-Security-Policy': "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'X-Content-Type-Options': 'nosniff',
  'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
};
const required = Object.keys(secureHeaders).map((name) => name.toLowerCase());

const server = createServer((request, response) => {
  for (const [name, value] of Object.entries(secureHeaders)) {
    response.setHeader(name, value);
  }
  if (request.url === '/ok') {
    response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
    response.end('ready');
    return;
  }
  response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  response.end('not found');
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  for (const path of ['/ok', '/missing']) {
    const response = await fetch(`http://127.0.0.1:${port}${path}`);
    const missing = required.filter((name) => !response.headers.has(name));
    console.log(path, response.status, `missing=${missing.join(',') || 'none'}`);
  }
  server.close();
});
/ok 200 missing=none
/missing 404 missing=none

部署测试还应触发认证失败、请求过大、限流和上游故障等响应。这些响应可能由网关生成,根本不会经过应用中间件。若前端需要读取错误状态,必须同时检查相应的CORS策略,但CORS与这里的安全响应头仍是不同配置。

陷阱

用宽松值换取“兼容”

修复方法: 从浏览器实际加载的资源清单建立策略。优先把脚本放入受控外部文件,确需内联时使用每次响应的nonce或经过审核的hash。每个新增来源都要记录所有者、用途、资源类型与移除条件,并在报告模式中观察后再执行。

重用nonce或只改策略一侧

修复方法: 在渲染每个响应时生成不可预测nonce,并通过同一个请求上下文传给策略构造器与模板。测试值每次变化且两侧严格相等。审计页面缓存,确保不会把一个用户化响应及其nonce错误复用于其他请求。

过早扩大HSTS范围

修复方法: 先清点所有子域名和证书自动续期路径,以短max-age验证,再逐步延长。只有整个域名空间满足预加载要求且团队接受较慢的撤销流程时,才添加includeSubDomains并提交预加载。回滚头只有浏览器再次通过HTTPS收到后才生效。

只保护成功响应

修复方法: 在能覆盖所有响应的公共层设置静态头,并为动态nonce保留明确接口。逐一触发应用与基础设施的重要状态码,使用原始HTTP检查最终字段,再用浏览器确认CSP、嵌入和权限行为。

混淆嵌入方向

修复方法: 用frame-ancestors控制谁能嵌入当前页面,用frame-src控制当前页面能加载哪些frame。无需嵌入时选择frame-ancestors 'none';需要合作方时列出准确来源。可保留兼容性的X-Frame-Options,但不能让它与CSP意图冲突。

继续启用过时的头

修复方法: 删除无法解释且没有目标浏览器支持依据的遗留字段。使用CSP约束脚本执行与frame-ancestors表达嵌入来源,只保留DENY或SAMEORIGIN形式的X-Frame-Options兼容值。用浏览器测试功能行为,而不是根据头的数量评分。

深入 渐进式部署与证据

渐进式部署与证据

CSP最适合按观察、修正、执行的顺序部署。先建立与目标策略相同的Content-Security-Policy-Report-Only,在真实流量和测试环境中收集违规,同时保留现有执行策略。报告模式只记录本来会被阻止的行为,不会保护用户,因此不能无限期停在这一步。

浏览器控制台适合开发调试,CSP报告端点适合发现更多页面路径。报告内容来自客户端,字段和URL都可能被攻击者控制;接收端要限制主体大小与速率、验证内容类型、转义日志并避免把查询参数中的秘密写入监控系统。违规数量也不是风险分数,同一个浏览器扩展或旧页面可能制造大量噪声。

报告稳定后,把策略放入小范围执行并保留可观测性。每次只缩小一个来源或移除一项关键字,测试主要页面、登录、支付、错误处理和后台任务界面。回滚应恢复上一份经过审核的策略,而不是临时添加*或'unsafe-inline'。

建议用一份版本化策略清单记录以下事实:

  1. 哪个响应类别使用该策略,以及最终由哪一层写入。
  2. 每个来源和关键字对应的产品功能、所有者与到期条件。
  3. 报告模式与执行模式的测试证据,包括被故意阻止的样本。
  4. 缩短或撤销HSTS、移除第三方来源和关闭功能时的回滚步骤。

CSP不是单一模板

营销页面、登录页面、富文本编辑器和JSON API的资源需求不同。给所有响应套用一条策略,通常会迫使团队不断放宽它。按响应类别共享经过审核的基线,再让少数路由显式增加最小例外,更容易看出一次变更扩大了什么权限。

JSON API不会执行页面脚本,但仍应发送正确的Content-Type和nosniff,并避免被误当成可下载HTML。文件下载要同时考虑媒体类型、Content-Disposition、用户可控文件名和托管来源。安全响应头不能修复路径遍历、恶意文件内容或错误授权。

报告策略与执行策略

同一个响应可以同时携带报告策略和执行策略,用于观察下一步收紧会影响什么。两者必须明确命名和版本化,否则排障人员很容易把控制台中的报告违规误认为实际阻止。测试应分别断言当前执行边界与候选边界,而不是只检查字段存在。

CSP报告不能证明所有允许行为都安全。策略允许的同源脚本若来自可写上传路径,仍可能执行;获准第三方脚本被攻破后,也在策略边界内。来源审查要继续检查内容所有权、发布权限、完整性与供应链。

HSTS的状态与回滚

HSTS与普通无缓存响应头不同,因为浏览器会在max-age秒内保留状态。先前收到的较长有效期不会因为服务器暂时停止发送该头而立即消失。要主动清除,浏览器需要通过有效HTTPS连接收到max-age=0,而无法连接的用户恰恰收不到这条回滚指令。

includeSubDomains把承诺扩展到当前主机下的子域名。若从顶级站点发送,未来新建的子域名也必须从第一次访问起提供有效HTTPS。DNS、证书签发、续期、紧急切换和第三方托管因此都属于HSTS变更评审范围。

预加载解决首次访问前还没有HSTS状态的窗口,但它把域名放入浏览器发布的数据中。响应里的preload令牌只是提交要求的一部分,不会自行加入列表。移除需要提交申请并等待浏览器数据更新,所以预加载是域名级长期承诺,不是一次普通的头值调整。

缓存与动态策略

静态安全头适合由边缘层统一添加;包含nonce、用户数据或路由特例的CSP必须与生成它的响应保持一致。若边缘缓存HTML,却在另一步重新生成CSP,nonce会不匹配。若缓存键没有覆盖策略差异,一个权限更宽的页面还可能被复用于权限更窄的路径。

策略测试应经过与生产相同的CDN、压缩、重定向和错误处理链。直接访问源站只能定位应用输出,不能证明最终行为。记录每一层添加、删除或规范化的字段,出现重复CSP或冲突frame策略时才能找到责任边界。

策略传递细节

HTTP字段名不区分大小写,但不同指令值有各自的语法。不要用通用“规范化器”把所有安全字段转成小写或用逗号连接。CSP来源可能包含区分大小写的URL路径,而多个策略字段表示多条分别执行的策略,不是一条逗号分隔的允许列表。

响应头是传递CSP的首选位置,因为它在受保护文档处理前到达,并支持完整策略模型。静态托管无法配置响应时,<meta http-equiv="Content-Security-Policy">可以提供部分能力,但它只支持一部分CSP,也不能提供HSTS、X-Content-Type-Options或其他仅属于HTTP的控制。

尤其不能用meta传递的CSP声称已经提供嵌入保护。Meta策略中的frame-ancestors会被忽略,攻击者可以在文档标记建立边界之前开始嵌入。应在HTTP响应中发送嵌入策略,并从真正不同的源验证结果。

响应头还必须覆盖正确范围。HTTP响应中的HSTS字段会被忽略;图片响应上的CSP不会反过来保护加载它的文档;嵌入文档的Permissions Policy与外层页面allow属性控制的上下文也不同。审查任何配置时,都要明确受保护的是哪个响应。

为每个生成响应头的部署层记录以下传递检查:

  • 上游值已经存在时,该层会添加、替换、追加还是删除字段。
  • 哪些状态码、内容类型、主机名和协议会收到该字段。
  • 重定向、缓存变体、自动生成错误与直接源站响应是否不同。
  • 哪项浏览器观察能证明策略被执行,而不只是字段存在。

这些检查应成为发布断言,不是一次性的配置笔记。框架升级、CDN规则调序或新增静态托管路径,都可能在不修改源策略对象的情况下改变最终传递结果。

浏览器验证矩阵

原始HTTP测试回答“最终发送了什么”,浏览器测试回答“用户代理怎样执行”。两者都需要。下面的矩阵为每项策略选择可观察的正向和负向证据。

边界正向测试负向测试
CSP脚本带正确nonce的脚本运行无nonce的内联脚本被阻止并产生违规
CSP连接允许的API请求成功未列出的目标被connect-src阻止
页面嵌入获准祖先可加载页面攻击者来源的frame无法显示页面
MIME正确JavaScript类型可加载把文本类型响应作为脚本加载时失败
Permissions Policy获准页面可请求目标功能被禁用的frame无法使用该功能
HSTS已记录策略后HTTP URL被内部升级证书错误不会退回明文连接

测试失败时先区分网络响应和浏览器执行。CSP控制台消息、Network面板、frame加载结果与功能API错误各自提供不同证据。自动化测试可以固定主要回归路径,但新浏览器版本、扩展与企业策略仍可能改变环境,因此发布验证要保留一个干净浏览器配置作为对照。

防护边界之外

安全响应头不能代替输出编码、参数化查询、服务端授权、CSRF防护、TLS配置或依赖治理。CSP也不是允许应用继续拼接不可信HTML的许可证;绕过点、受信来源中的弱点和已授权脚本都可能让攻击继续发生。

同样,头缺失不一定代表所有响应都应添加同一字段。纯API、公共静态资源、下载文件和可嵌入组件的需求不同。先写威胁模型和响应分类,再决定哪些头适用、由谁管理以及如何证明其行为。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

前置内容 Web安全基础 Https tls 即将上线
下一篇 CORS Input validation 即将上线 API安全 服务端请求伪造
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?