● CWE-942 · OWASP A05:2021

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 包会为动态来源自动添加它。

攻击执行流程分解

步骤 1

受害者已登录 API 所在网站

他的浏览器保存着 api.example.com 的会话 Cookie,跨站请求时也会发送。

步骤 2

他打开了攻击者域名上的页面

页面脚本调用 fetch('https://api.example.com/api/account', { credentials: 'include' })。一个位于 https://attacker-example.com、只在控制台打印响应长度的页面演示了这种情况。

步骤 3

API 认可了这个来源

后缀检查接受了 attacker-example.com,于是响应在 Access-Control-Allow-Origin 中带上这个来源,并附带 Access-Control-Allow-Credentials: true。

步骤 4

脚本读取到私密数据

浏览器把账户 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));
});

工程与系统安全加固清单

参考来源

← 浏览完整的安全目录 全部漏洞指南 →