CORS 与安全响应头
本教程共 76 篇 · 第 57 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
57. CORS 与安全响应头
本节目标:- 同源策略为什么要存在,什么算「跨域」 - CORS 的简单请求和预检(preflight)两套逻辑 - 用
cors包在 Express 里正确放行 - 用helmet给响应加上 CSP、HSTS 等安全头
你前端跑在 http://localhost:5173,后端在 http://localhost:3000——浏览器立刻给你报一个错:Blocked by CORS policy。这玩意儿几乎每个全栈新手都撞过。它背后是浏览器的同源策略(Same-Origin Policy),而 CORS 是浏览器和服务器之间约定的「跨域放行协议」。
这一章先讲清楚同源策略是什么、CORS 怎么工作,再讲一组能用 helmet 一键加上的安全响应头——它们跟鉴权一样,是生产环境的基本盘。
同源策略:浏览器的「门禁」
「同源」要求协议、域名、端口三者完全一致。下面这几个都和 https://example.com 不同源:
| URL | 是否同源 | 原因 |
|---|---|---|
https://example.com/page | 是 | 协议、域、端口全同 |
http://example.com | 否 | 协议不同 |
https://api.example.com | 否 | 域名不同 |
https://example.com:8080 | 否 | 端口不同 |
同源策略限制了网页里的脚本不能随便读另一个源的资源。这是道安全门:否则你登录了银行,随便一个恶意网页就能用你的身份去调银行接口。
但现代前后端分离开发,跨域请求是刚需。CORS(跨源资源共享,Cross-Origin Resource Sharing)就是给这道门加了把「白名单锁」——服务器在响应头里说「我允许来自某某源的请求」,浏览器才放行。
CORS 的两种请求
浏览器把跨域请求分两类,处理方式不一样:
简单请求:满足一定条件(比如方法只用了 GET/POST/HEAD,且没有自定义头)时,浏览器直接发,再根据响应头里的 Access-Control-Allow-Origin 决定是否让你读结果。
预检请求(preflight):如果带了自定义头(比如 Authorization: Bearer xxx)、或者用了 PUT/DELETE、或者 Content-Type 是 application/json,浏览器会先发一个 OPTIONS 请求探路,问服务器「我接下来要这么干,你允许吗」,服务器同意了才发真正的请求。
服务器这边要回的关键响应头:
| 响应头 | 作用 |
|---|---|
Access-Control-Allow-Origin | 允许哪些源,可填具体域名或 * |
Access-Control-Allow-Methods | 允许哪些 HTTP 方法 |
Access-Control-Allow-Headers | 允许哪些请求头 |
Access-Control-Allow-Credentials | 是否允许带凭证(Cookie) |
Access-Control-Max-Age | 预检结果缓存多久,省得每次都问 |
用 cors 包落地
手写这些头很烦,cors 包(2.8.x)一行就搞定:
npm install cors
import express from 'express';
import cors from 'cors';
const app = express();
// 默认:允许所有源(相当于 Access-Control-Allow-Origin: *)
app.use(cors());
app.get('/api/data', (req, res) => {
res.json({ ok: true });
});
生产环境别用 * 裸放行,要指定可信的前端域名:
app.use(cors({
origin: 'https://my-frontend.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
带凭证的跨域
如果你的接口依赖 Cookie(比如上一章的 refresh token 种在 Cookie 里),必须两端配合:
app.use(cors({
origin: 'https://my-frontend.com',
credentials: true, // 允许浏览器带上 Cookie
}));
Warning一旦
credentials: true,Access-Control-Allow-Origin不能再写*,必须是一个明确的源,否则浏览器直接拒绝。这是 CORS 规范硬性规定的,很多人卡在这儿。前端那边发请求时也要fetch(url, { credentials: 'include' })。
CORS 防的不是服务器,是浏览器
得说清楚一个容易误解的点:CORS 不是服务器的安全防线。它本质上是浏览器在「收到跨域响应后,决定要不要把数据交给网页脚本」的规则。如果你用 curl 或者自己的后端去调那个接口,根本不受 CORS 限制——CORS 拦不住一个恶意的服务端程序,它只拦「浏览器里的网页脚本」。
所以别指望靠 CORS 来保密数据。真正该做的授权(谁有权读这个接口)还得靠第 56 章的鉴权中间件。CORS 只是用来声明「我的资源允许哪些网页来读」,是个协作约定,不是防火墙。
支持多个前端域名、正确处理预检
如果你的接口要同时给 app.example.com 和 admin.example.com 用,origin 就不能写死一个字符串。它可以是个函数,按请求动态判断:
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://app.example.com', 'https://admin.example.com'];
// 允许白名单内的源,以及没有 origin 的非浏览器请求(如 curl、服务端调用)
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS 不允许该源'));
}
},
credentials: true,
}));
// 显式处理预检 OPTIONS 请求(多数 cors 配置会自动处理,这里便于理解)
app.options('/api/data', cors());
Tip如果你手动处理预检(没用
cors包),记得对OPTIONS请求直接返回 204,并且把Access-Control-Allow-*头设全,否则浏览器不会发真正的请求。用cors包的话这些它都替你做好了。
helmet:一键安全响应头
CORS 解决的是「谁能调我」,但还有一类风险是「浏览器拿到我的响应后会干傻事」——比如执行了响应里的恶意脚本、被塞进钓鱼 iframe。这些靠一组安全响应头来防,helmet(7.x / 8.x 皆可)能帮你一键加上一整套。
npm install helmet
import helmet from 'helmet';
app.use(helmet()); // 一行加上默认全套安全头
helmet() 默认会设置这些关键头(不同版本略有差异):
- Content-Security-Policy(CSP):告诉浏览器「只允许从哪些来源加载脚本/图片/样式」。它是防 XSS 的终极防线——即便有人往页面里注入脚本,只要来源不在白名单,浏览器就不执行。
- X-Content-Type-Options: nosniff:禁止浏览器「猜」响应内容的 MIME 类型,防止 MIME 嗅探攻击。
- X-Frame-Options / frameguard:阻止别人把你的页面塞进
<iframe>搞点击劫持(clickjacking)。 - Strict-Transport-Security(HSTS):告诉浏览器「之后一段时间里只准用 HTTPS 访问我」,防 SSL 剥离。
- Referrer-Policy:控制请求里带多少来源信息出去,避免泄露敏感路径。
- Permissions-Policy:限制网页能用哪些浏览器高级能力(摄像头、麦克风、地理位置等)。
自定义 CSP
默认 CSP 往往太严,你的前端/CDN 加载不了资源。按需放宽:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://img.example.com'],
},
}));
TipCSP 是防 XSS 最有效的一招,但它要求你对「页面会从哪加载东西」心里有数。上线前先用
Content-Security-Policy-Report-Only模式跑一阵,把违规报告收集起来调白名单,别一上来就defaultSrc: 'none'把自家页面也锁死。
反向代理场景
如果你用 Nginx 这类反向代理在前面,helmet 设的头可能被代理覆盖或丢失。常见做法是让 Nginx 来设安全头,或者确认代理配置 proxy_pass_header 把头透传过去。两边只留一边设,避免冲突。
Warning安全头不是「加了就无敌」。
helmet解决了很大一块,但它不替你做输入校验、不防逻辑漏洞。下一章专门讲输入校验和各种注入攻击的防护,那才是安全闭环里最硬的一块。
把 CORS 配成「最小必要放行」、把 helmet 安全头开起来,你的服务在浏览器侧就立起了两道像样的门。但攻击者更多时候是从「你信任的用户输入」下手——这才是下一章的重点。