5 个最容易写错的 HTTP 头:Cache-Control、CORS、CSP 的坑和正确写法

· 约 4 分钟 📨 HTTP 头速查

HTTP 响应头是服务端和客户端之间的”合同条款”。写错了,轻则缓存失效导致用户总是加载慢,重则 CORS 报错让前端完全无法工作,或者 CSP 把自己的 JS 拦掉导致页面白屏。

误用一:Cache-Control 逻辑搞反

错误写法

Cache-Control: no-cache, max-age=0

本意是”不要缓存”,实际效果是”每次都要验证缓存”,服务器回 304 时浏览器还是用缓存。

正确写法

# 完全禁止缓存(敏感页面)
Cache-Control: no-store

# 条件缓存(HTML 页面,内容可能变化)
Cache-Control: no-cache

# 强缓存(带 hash 的静态资源)
Cache-Control: max-age=31536000, immutable

Cache-Control 指令速查

指令含义
no-store不存储,每次重新下载
no-cache存储,但每次使用前必须验证
max-age=N存储 N 秒,期间不验证直接用
immutable内容永远不变,永远不发条件请求
must-revalidate过期后必须验证,不允许使用过期缓存
private只允许浏览器缓存,不允许代理缓存
public浏览器和代理都可以缓存

最佳实践:HTML 文件用 no-cache(每次验证,内容变化时立即生效);带 hash 的 JS/CSS/图片用 max-age=31536000, immutable(永久缓存,hash 变了就是新 URL)。

误用二:CORS 只处理了正常请求,忘了预检

问题现象

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' 
has been blocked by CORS policy: Response to preflight request doesn't pass access 
control check: It does not have HTTP ok status.

根源

浏览器在发实际请求前,自动发了一个 OPTIONS 请求(预检)。服务端路由没有注册 OPTIONS 方法,框架默认返回 405 Method Not Allowed。

修复

Express.js

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'https://app.example.com');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);  // 预检直接返回 204
  }
  next();
});

Nginx

location /api/ {
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
    add_header Access-Control-Allow-Headers 'Authorization, Content-Type' always;
    if ($request_method = OPTIONS) {
        return 204;
    }
}

触发预检的条件

满足任意一条就会触发 OPTIONS 预检:

  • 请求方法不是 GET / POST / HEAD
  • Content-Type 不是 text/plain / application/x-www-form-urlencoded / multipart/form-data
  • 有自定义请求头(AuthorizationX-Request-ID 等)

误用三:Content-Type 漏掉 charset

错误写法

Content-Type: text/html
Content-Type: application/json

正确写法

Content-Type: text/html; charset=UTF-8
Content-Type: application/json; charset=UTF-8
Content-Type: text/plain; charset=UTF-8

浏览器遇到 text/html 没有 charset,会查 <meta charset="UTF-8"> 标签——但如果 HTML 本身就是乱码的,meta 标签也读不出来。服务端直接声明 charset 是最可靠的。

误用四:Vary 头缺失导致缓存不分语言

问题场景

API 根据 Accept-Language 返回中文或英文内容,但 CDN 缓存了第一个请求的内容,后来的请求不管什么语言都返回同一份。

原因

没有告诉 CDN “这个响应因 Accept-Language 而变化”。

修复

Vary: Accept-Language

或者同时因多个头变化:

Vary: Accept-Encoding, Accept-Language

Vary 告诉代理/CDN:缓存时把 Vary 里列出的请求头作为缓存 key 的一部分。没有 Vary,代理只用 URL 做 key——同 URL 不同语言的用户拿到同一份缓存。

误用五:安全头遗漏或配置过松

最基础的安全头组合

# 防止点击劫持(用 iframe 嵌套你的页面)
X-Frame-Options: DENY

# 防止 MIME 嗅探(浏览器猜 Content-Type)
X-Content-Type-Options: nosniff

# 强制 HTTPS(需配合 HSTS preload 才能保护首次访问)
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

# 内容安全策略(防 XSS)——先用 report-only 观察,再上 enforcement
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{随机值}'

# 控制 Referer 信息泄露
Referrer-Policy: strict-origin-when-cross-origin

快速验证

# 用 curl 查看响应头
curl -I https://yourdomain.com

# 或用在线工具 securityheaders.com 扫描评分

配套工具

❓ 常见问题

Cache-Control no-cache 和 no-store 有什么区别?

no-cache 不是"不缓存",no-store 才是no-cache:浏览器可以缓存,但每次使用前必须向服务器验证(发 If-None-Match 或 If-Modified-Since 条件请求),服务器确认没变化才用缓存(返回 304)。no-store:完全不存储,每次都重新下载。误用 no-cache 的开发者本意是"不要缓存",但实际上只是让缓存失效的快一些(每次都验证)。想真正禁止缓存用 no-store,想要条件缓存用 no-cache。敏感页面(银行账单、个人信息)应该用 no-store 防止缓存留在用户设备上。

CORS 预检(preflight)为什么会失败?

预检是浏览器自动发出的 OPTIONS 请求,服务器没有正确响应这个 OPTIONS 就会失败。触发预检的条件(三选一满足即触发):(1) 非简单请求方法(PUT/DELETE/PATCH);(2) 自定义请求头(Authorization、X-Custom-Header 等);(3) Content-Type 不是 text/plain / application/x-www-form-urlencoded / multipart/form-data 三者之一。服务器必须对 OPTIONS 请求回 200/204,并带上 Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers。常见错误:路由只处理了 POST/GET,OPTIONS 请求打到了空路由返回 404。

Content-Type 的 charset 什么时候需要加?

文本类型必须加,二进制类型不需要Content-Type: text/html; charset=UTF-8——浏览器用 charset 决定怎么解码响应体,不加的话浏览器自己猜(通常猜 Latin-1 或系统默认,中文必乱码)。需要加 charset 的类型:text/html、text/plain、text/css、application/json(JSON 规范要求 UTF-8,但加上更明确)、application/javascript。不需要的:image/jpeg、image/png、application/octet-stream——这些是字节流,charset 无意义。常见错误:API 返回 JSON 但 Content-Type 写成 application/json 不带 charset,部分客户端解析中文失败。

HSTS 第一次访问为什么保护不了?

HSTS(HTTP Strict Transport Security)只保护第二次及以后的访问。首次访问时用户还在用 HTTP,HSTS 响应头是在 HTTPS 响应里发的——第一次没有 HTTPS 就没有 HSTS。攻击者可以在首次访问时拦截 HTTP 流量(SSL strip 攻击)。解决方案:(1) HSTS Preload——把域名提交到浏览器内置的 preload 列表(hstspreload.org),这样第一次访问就是 HTTPS;(2) 要求:max-age ≥ 31536000(一年)、includeSubDomains、preload 三个指令都要有。注意:一旦加入 preload 列表,移除非常麻烦(要等浏览器更新),域名必须能长期维护 HTTPS。

CSP 怎么避免把自己的脚本拦掉?

CSP 初次部署建议用 report-only 模式先观察,不要直接上 enforcement。步骤:(1) 先加 Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report——只上报不拦截;(2) 观察 /csp-report 收到的违规报告,列出所有需要白名单的来源;(3) 确认白名单完整后,改成 Content-Security-Policy(enforcement 模式)。常见漏掉的来源:Google Analytics(需加 script-src www.google-analytics.com)、字体 CDN(需加 font-src fonts.gstatic.com)、内联样式(需加 style-src 'unsafe-inline' 或改用 nonce)。永远不要加 unsafe-eval 除非你用了必须依赖 eval 的第三方库。

📨 打开 HTTP 头速查 70+ 请求/响应头按 11 类整理·缓存/CORS/CSP/HSTS/Cookie/Auth·中文释义+示例值一键复制+MDN 链接