#React#DevOps

CORS 与 CSP:Web 安全的两道防线

CORS 和 CSP 名字像但完全不同:CORS 管跨域请求,CSP 管页面能加载什么。本文讲清 CORS 的预检/简单请求/凭证、CSP 的指令(restrict 来源/禁内联)、两者各自的坑,以及上生产必配的一套安全 header。

本文要点

  • 主题聚焦:React、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月1日·更新于 2026年7月23日

我见过太多人把 CORS 和 CSP 混为一谈——以为配了 CSP 就不用管 CORS,或者反过来。更常见的是后端同学为了"解决跨域报错",直接在响应里写一句 Access-Control-Allow-Origin: * 然后拍屁股走人,留下一个能被任意网站带着用户凭证发起请求的口子。这两个东西名字像,职责完全不同:CORS 是浏览器替你放宽同源限制的协商机制,CSP 是你主动收紧页面能加载什么的安全策略。一个对外松绑,一个对内收紧。这篇把它们各自讲透,并附一套上生产就该配上的安全 header。

一、先分清:CORS 和 CSP 到底各管什么

一句话区分:

  • CORS(Cross-Origin Resource Sharing):浏览器默认禁止页面读跨源响应。CORS 是服务端用一组响应头告诉浏览器"这个源可以读我",是放宽同源策略的机制。
  • CSP(Content Security Policy):浏览器默认页面能加载任何来源的资源。CSP 是你用响应头告诉浏览器"只允许从这些源加载脚本/样式/图片",是收紧资源加载的策略。

一个对外、一个对内;一个是"放行"的协商,一个是"约束"的白名单。它们的交集只有在 XSS 攻击场景下才会被一起讨论——CSP 挡住恶意脚本注入执行,CORS 挡住恶意站点读取你的 API。把这两道防线都配好,Web 端的安全基本面才算及格。

二、CORS 的底层逻辑:同源策略到底在拦什么

同源策略(Same-Origin Policy)是浏览器的铁律:协议 + 域名 + 端口三者完全一致才是"同源",跨源的请求默认不允许 JS 读响应。注意三个细节:

  1. 拦的是"读",不是"发"。跨源的 <img>、<script>、<form> 提交早就发出去了,只是 JS 读不到结果。CORS 本质是给 fetch / XHR 这类能读响应的 JS 请求开口子。
  2. 是浏览器在执行,不是服务端。服务端照样收到请求、照样处理,只是浏览器把响应扣下了。所以"在后端拦截 CORS"是误判——你要么在网关层返回正确的 CORS 头,要么在前端走同源代理。
  3. 简单请求和预检请求走两条路,这正是新手最容易踩坑的地方。

三、简单请求 vs 预检请求:浏览器在替你挡什么

浏览器把跨域请求分成两类:

简单请求:方法是 GET/HEAD/POST,头部只有几个安全列表里的(Accept、Content-Type 为 text/plain/multipart/form-data/application/x-www-form-urlencoded 等),且不带自定义头。这类请求浏览器直接发,服务端在响应里带上 CORS 头即可。

预检请求(Preflight):只要带了自定义头(比如 Authorization 是 OK 的,但 X-Requested-With、X-CSRF-Token 之类不行)、方法不是上面三个、或者 Content-Type 是 application/json,浏览器就先发一个 OPTIONS 探路:

text
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

服务端必须正确响应这个 OPTIONS,告诉浏览器"我允许这个方法、这些头",浏览器才会发真正的请求。最常见的坑有两个:一是后端框架把 OPTIONS 当成 404 路由(因为没注册),二是网关层直接把 OPTIONS 拦在鉴权中间件外却没返回 CORS 头。只要前端报"CORS preflight 失败",九成是这两个之一。

预检会带来额外一次往返,可以用 Access-Control-Max-Age: 86400 让浏览器缓存这次预检的结果,一天内同一请求不再重复预检。

四、CORS 的四个关键头,与 credentials 那个大坑

理解四个响应头就够应付 90% 的场景:

text
Access-Control-Allow-Origin:  https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

这里有一个必须刻进脑子的规则:当 Access-Control-Allow-Credentials: true(前端 fetch(url, { credentials: 'include' }) 或 Axios 的 withCredentials: true),Access-Control-Allow-Origin 绝对不能是 *,必须是一个具体的、和请求 Origin 完全匹配的值。

为什么?因为带凭证意味着请求会自动附上用户的 Cookie。如果允许 *,意味着任意网站都能带着用户在你站点上的登录态,向你的 API 发请求并读到响应——这就是经典的 CSRF + 数据泄露组合拳。浏览器在这一点上把关很严:发现 Credentials: true 同时 Allow-Origin: *,直接拒绝,响应读不到。

正确做法是后端读请求的 Origin 头,在白名单里查,命中就回写具体值:

js
const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);
const origin = req.headers.origin;
if (allowed.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin');          // 别忘了,否则 CDN 缓存会串
  res.setHeader('Access-Control-Allow-Credentials', 'true');
}

Vary: Origin 是另一个高频遗漏:不加,CDN 会把一个源的 CORS 响应缓存给所有源,直接导致跨源串台。配上 credentials: true,就配白名单 + Vary: Origin,这是底线。

五、CSP:限制页面能加载什么

CSP 是通过响应头下发的白名单策略。最简单的例子:

text
Content-Security-Policy: default-src 'self';

这一句意味着:页面默认只能从同源加载所有资源(脚本、样式、图片、字体、连接、框架……)。default-src 是兜底,具体指令没写就回落到它。常用指令:

  • script-src:JS 来源(防 XSS 的核心)
  • style-src:CSS 来源
  • img-src / font-src / connect-src(fetch/WebSocket 的目标)/ frame-src
  • object-src 'none':禁掉 Flash/插件,基本永远该禁

一个真实业务的 CSP 大概长这样:

text
Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com https://*.sentry.io;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

frame-ancestors 'none' 顺手替代了老的 X-Frame-Options: DENY,防点击劫持。base-uri 'self' 防 <base> 标签被注入篡改。

六、禁内联与 nonce/hash:CSP 防 XSS 的真正杀招

CSP 防御 XSS 的核心,是默认禁止内联脚本执行。即便攻击者把 <script>steal()</script> 注进了你的页面 DOM,CSP 也会让这段脚本根本不执行——这是它能拦住注入类攻击的根本原因。

但禁内联会和现状打架:你的 React 里可能有 <script> 内联引导数据、第三方 SDK 要内联、老页面一堆 onclick=。CSP 给了三个放行机制:

  • 'unsafe-inline':直接放行所有内联。一旦写了它,CSP 防御 XSS 的能力基本作废,只能用来挡外部加载。上线初期可以临时用,但目标必须是干掉它。
  • nonce:服务端为每次响应生成一个随机串,放在 CSP 头和内联脚本的 nonce 属性里,只有带这个 nonce 的内联才执行。
text
Content-Security-Policy: script-src 'self' 'nonce-3q8c2r9b'
html
<script nonce="3q8c2r9b">console.log('ok')</script>
  • hash:把内联脚本的内容算一个 sha256,写在 CSP 里。适合内容固定的内联。

Next.js / Nuxt 等现代框架都能自动为内联脚本注入 nonce,配 CSP 是顺手的事。目标:让 script-src 既不含 'unsafe-inline',也不含 'unsafe-eval',这才是 CSP 真正发力的状态。

七、CORS 与 CSP 的典型坑

CORS 的坑:

  • Allow-Origin: * + Credentials: true:上面说过,这是直接的漏洞。浏览器虽然会拒绝,但有些网关/代理会"好心"地把通配符改成具体 Origin 同时保留 Credentials: true,等于把白名单逻辑做崩了——务必自己控制。
  • 没处理 OPTIONS:框架默认路由不接管 OPTIONS,导致预检 404。
  • 预检缓存太短:每次请求都先 OPTIONS 一次,接口 RT 直接翻倍。设 Access-Control-Max-Age。
  • 在 SPA 里把 CORS 当成鉴权:CORS 只是浏览器的隔离,不是认证。curl / 原生客户端不受 CORS 约束,真正的权限校验必须在服务端做,别指望浏览器替你挡住未授权访问。

CSP 的坑:

  • 太严误伤业务:一上来就 default-src 'self',结果第三方字体、统计 SDK、内联样式全挂,页面样式崩成裸奔。第三方域名必须逐个加进白名单。
  • style-src 没留 'unsafe-inline':很多框架(包括老版 Tailwind / styled-components 的运行时模式)需要内联样式,直接挡会样式全丢。可以先用 'unsafe-inline',后续再迁 nonce。
  • connect-src 漏了 WebSocket / Sentry / 分析上报域名:前端 fetch 直接 ERR_BLOCKED_BY_RESPONSE,但页面本身不报错,只在控制台悄悄失败,排查极慢。调试期必看 Console 的 CSP 违规警告。

八、上生产必配的一套安全 header

不管你的应用大小,这一套都是底线。在 Nginx 或 Next.js 的 headers() / Vercel 的 headers 配置里加上:

text
# 强制 HTTPS + 预加载(先确认站点能全站 HTTPS 再加 preload)
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

# 防 MIME 嗅探,IE 时代的遗产但依然要配
X-Content-Type-Options: nosniff

# 防点击劫持(CSP frame-ancestors 是现代替代,这个作为兜底)
X-Frame-Options: DENY

# 控制 Referer 暴露多少
Referrer-Policy: strict-origin-when-cross-origin

# 防降级 MIME 驱动的浏览器特性
X-Permitted-Cross-Domain-Policies: none

# CSP(下面单独说怎么渐进上)
Content-Security-Policy: ...

HSTS 一旦配上,浏览器在 max-age 内会强制只用 HTTPS 访问你的站点,中间人无法把用户降级到 HTTP 做劫持。但反过来,配 HSTS 之前必须确认整站(包括子域)都能跑 HTTPS,否则会把仍在 HTTP 的子域直接锁死。preload 列表更要谨慎,一旦提交相当于写进浏览器源码,撤销要走流程。

九、渐进上 CSP:report-only → 观察 → 收紧 → enforce

直接 enforce CSP 是高危操作——白名单漏一个域名,线上某个功能就静默挂掉。正确姿势是分四步走:

第一步:Report-Only 模式。 把头名换成 Content-Security-Policy-Report-Only,策略照写,但浏览器只报告不拦截。违规会发到你指定的上报端:

text
Content-Security-Policy-Report-Only:
  default-src 'self';
  report-uri /csp-report;
  report-to csp-endpoint;

第二步:收集并观察。 /csp-report 收到的每条违规都是一个 document-uri、violated-directive、blocked-uri。把所有被拦的域名汇总成一张表,逐个判断是不是业务必需。

第三步:收紧策略。 把观察到的合法域名补进白名单,干掉 'unsafe-inline'、上 nonce,直到 Report-Only 几乎不再有新报告。

第四步:切换为 enforce。 把头名换回 Content-Security-Policy,保留 report-uri 继续监控。任何后续新接入的第三方域名会立刻在报告里冒头,你能第一时间发现并补白名单。

这套"先观察再收紧"的流程,是把 CSP 安全落地到生产而不背事故的唯一稳妥路径。任何"我抄了一份 CSP 头直接上线"的操作,大概率一周内会出事。

小结

  1. CORS 是放宽同源策略、CSP 是收紧资源加载,前者对外协商放行,后者对内约束白名单,别再混为一谈。
  2. CORS 的死穴在 credentials: true + Allow-Origin: *——浏览器会拒,但别把这道防线交给浏览器,自己用 Origin 白名单 + Vary: Origin 守死。
  3. CSP 防 XSS 的真正杀招是禁内联脚本;目标必须是干掉 'unsafe-inline' 和 'unsafe-eval',用 nonce/hash 白名单替代。
  4. 上生产必配的一套 header(HSTS / nosniff / X-Frame-Options / Referrer-Policy / CSP)是 Web 安全的基本面,缺一个都是裸奔。
  5. CSP 必须渐进上线:Report-Only 观察 → 补白名单 → 干掉 unsafe-inline → enforce。直接 enforce 等于自残。

Web 安全的底层逻辑一句话:默认收紧,按需放开,放开的部分必须是你能解释清楚为什么放的那部分。 CORS 和 CSP 都是这句话的两面——一个让你能解释清楚"为什么让这个跨源读我",一个让你能解释清楚"为什么让这个脚本跑在我页面上"。凡是解释不清楚的放行,迟早变成线上事故的根因。

Related

相关推荐