CORS 配置错误(CWE-942):反射 Origin 如何泄露数据以及如何修复
如果一个 API 对任意 Origin 都回应等于该来源的 Access-Control-Allow-Origin 和 Access-Control-Allow-Credentials: true,那么任何网站都能带着访问者的 Cookie 读取它的响应。本文说明后缀检查、null 来源和反射来源如何破坏同源策略,以及用精确匹配的允许列表来修复,并给出 Express 和 cors 的示例。
通俗易懂的原理解析 (ELI5)
银行柜员会把你的余额念给你派去的人听,只要这个人出示一封写明替谁办事的信,而且柜员的名单认可这个名字。有位柜员的规则是“凡是以‘张’字结尾的名字都认可”。一个陌生人派人拿着“李张”的信来,柜员认可了,你的余额就被念给了一个你从未派去的人。反射 Origin 则是更偷懒的做法,柜员对所有名字都认可。修复方法是一份简短而精确的名单,逐字比对。
核心概念与专有名词
- 同源策略
- 浏览器只允许脚本读取与自己同源(协议、主机和端口相同)的响应。CORS 响应头是服务器有意为指定来源放宽这条规则的方式。
- 带凭据的 Access-Control-Allow-Origin
- 如果响应在
Access-Control-Allow-Origin中写出调用方的来源,并发送Access-Control-Allow-Credentials: true,那么该来源上的脚本就能读取带有用户 Cookie 的请求的响应。浏览器拒绝*与凭据同时使用,所以配置错误的服务器会直接回显来源。 - 反射 Origin 与后缀匹配
- 把
Origin请求头原样复制到响应里,等于信任所有网站。像endsWith('example.com')这样的检查、子串匹配,或者没有锚点且点号未转义的正则表达式,也会接受https://attacker-example.com。 - null 来源
- 沙箱 iframe、
data:URL 和某些重定向会发送Origin: null。任何攻击者都能制造它,所以允许null就等于允许所有人。 - Vary: Origin
- 当
Access-Control-Allow-Origin随请求变化时,响应必须带上Vary: Origin,以免共享缓存把一个来源的 CORS 头返回给另一个来源。cors包会为动态来源自动添加它。
攻击执行流程分解
受害者已登录 API 所在网站
他的浏览器保存着 api.example.com 的会话 Cookie,跨站请求时也会发送。
他打开了攻击者域名上的页面
页面脚本调用 fetch('https://api.example.com/api/account', { credentials: 'include' })。一个位于 https://attacker-example.com、只在控制台打印响应长度的页面演示了这种情况。
API 认可了这个来源
后缀检查接受了 attacker-example.com,于是响应在 Access-Control-Allow-Origin 中带上这个来源,并附带 Access-Control-Allow-Credentials: true。
脚本读取到私密数据
浏览器把账户 JSON 交给攻击者的脚本,脚本可以把它发到任何地方。2016 年 James Kettle 在多家比特币交易所发现了这种模式,泄露了 API 密钥和钱包数据。
源代码对比:漏洞与安全实现
// server.js:任何以 example.com 结尾的来源以及 null 都能带着 Cookie 读取
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// 返回 true 会让 cors 连同凭据一起回显调用方的 Origin
app.use(cors({
origin: (origin, callback) => callback(null, !origin || origin === 'null' || origin.endsWith('example.com')),
credentials: true,
}));
// 私密账户数据可被已认可的来源读取
app.get('/api/account', requireSession, (req, res) => {
res.json(accountFor(req.session.userId));
});
// server.js:只允许两个精确的来源
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// 带协议和主机的完整来源,精确比较
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
// 未知或缺失的 Origin 得不到任何 CORS 头;cors 会添加 Vary: Origin
app.use(cors({
origin: (origin, callback) => callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin)),
credentials: true,
methods: ['GET', 'POST'],
maxAge: 600,
}));
// 个人数据绝不会被共享缓存保存
app.get('/api/account', requireSession, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(accountFor(req.session.userId));
});
工程与系统安全加固清单
- 维护一份按完整来源(协议、主机和端口)精确匹配的允许列表,并用
Set查找比较;绝不回显Origin,也不要使用后缀、子串或无锚点的正则检查。 - 绝不允许
null来源,也绝不为你不控制的来源开放凭据。 - 只要
Access-Control-Allow-Origin按请求决定,就发送Vary: Origin;对包含个人数据的响应发送Cache-Control: no-store。 - 把会话 Cookie 设为
SameSite=Lax或Strict,让跨站请求不会携带它们。 - 在 CI 中添加测试,向需要登录的路由发送
Origin: https://attacker.example和Origin: null,如果响应中出现Access-Control-Allow-Origin就判定失败。