CORS 跨域

CORS(Cross-Origin Resource Sharing,跨域资源共享)是一种机制,它使用额外的 HTTP 头来告诉浏览器允许一个源(域、协议、端口)的 Web 应用访问另一个源上的资源。在前后端分离的开发模式中,CORS 几乎是必会遇到的问题。本章将详细解释 CORS 的来龙去脉,以及在 Node.js/Express 中如何正确配置 CORS。

1. 什么是同源策略?

同源策略是浏览器的一项安全功能,它限制了一个源加载的文档或脚本与另一个源的资源进行交互。同源的定义是:协议(protocol)、域名(host)和端口(port)都相同。例如:

  • http://example.com/page1http://example.com/page2 同源。
  • http://example.comhttps://example.com 不同源(协议不同)。
  • http://example.comhttp://api.example.com 不同源(子域名不同)。
  • http://example.com:8080http://example.com:3000 不同源(端口不同)。

同源策略限制了一些跨域操作,例如:

  • 跨域 XMLHttpRequestfetch 请求。
  • 跨域 DOM 操作(如 iframe 内容访问)。
  • 跨域 Cookie、LocalStorage 访问。

CORS 提供了一种安全的跨域请求方式,服务器通过特定的响应头来告知浏览器是否允许跨域访问。

2. 简单请求与预检请求

CORS 将请求分为两类:简单请求和预检请求。

2.1 简单请求

满足以下所有条件的请求被视为简单请求:

  • 方法为 GETHEADPOST
  • 人工设置的请求头仅限于 AcceptAccept-LanguageContent-LanguageContent-Type(且值为 application/x-www-form-urlencodedmultipart/form-datatext/plain)。
  • 请求中的 XMLHttpRequest 对象没有注册任何事件监听器。

对于简单请求,浏览器直接发送请求,并在请求头中携带 Origin 字段。服务器根据 Origin 决定是否允许,并在响应头中返回 Access-Control-Allow-Origin 等字段。

2.2 预检请求

对于不满足简单条件的请求(例如使用 PUTDELETE 方法,或携带自定义头如 Authorization),浏览器会先发送一个 OPTIONS 预检请求,询问服务器是否允许实际请求。预检请求包含以下头:

  • Access-Control-Request-Method:实际请求的方法。
  • Access-Control-Request-Headers:实际请求携带的自定义头。

服务器收到预检请求后,应返回相应的 CORS 头,表明是否允许。如果允许,浏览器才会发送真正的请求。

3. CORS 相关响应头

服务器通过设置以下响应头来控制 CORS 行为:

  • Access-Control-Allow-Origin:必需。指定允许的源,可以是具体源或 *(通配符,表示所有源,但不能与凭证同时使用)。
  • Access-Control-Allow-Methods:指定允许的 HTTP 方法,用于预检请求响应。
  • Access-Control-Allow-Headers:指定允许的请求头,用于预检请求响应。
  • Access-Control-Allow-Credentials:布尔值,表示是否允许携带凭证(如 Cookie、HTTP 认证)。若为 true,则 Access-Control-Allow-Origin 不能为 *
  • Access-Control-Expose-Headers:指定哪些响应头可以被客户端访问(默认只有 Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma)。
  • Access-Control-Max-Age:预检请求的缓存时间(秒),在此时间内无需重复预检。

4. 在 Express 中配置 CORS

Express 应用可以使用 cors 中间件快速实现 CORS 支持。这是最推荐的方案。

安装:

npm install cors

4.1 全局启用 CORS(允许所有源)

const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors()); // 允许所有源

app.get('/api/users', (req, res) => {
  res.json([{ name: 'Alice' }]);
});

4.2 配置特定选项

可以通过传递配置对象来细化 CORS 行为:

const corsOptions = {
  origin: 'https://example.com',        // 允许的源
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['Content-Range', 'X-Content-Range'],
  credentials: true,                     // 允许携带凭证
  maxAge: 600,                           // 预检请求缓存10分钟
  preflightContinue: false,
  optionsSuccessStatus: 204
};

app.use(cors(corsOptions));

origin 也可以是一个函数,用于动态决定是否允许:

const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = ['https://site1.com', 'https://site2.com'];
    if (allowedOrigins.includes(origin) || !origin) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  }
};

4.3 为特定路由启用 CORS

可以只为某些路由启用 CORS,而不是全局:

app.get('/api/public', cors(), (req, res) => {
  res.json({ message: '公开数据' });
});

app.post('/api/secure', cors({ origin: 'https://example.com' }), (req, res) => {
  res.json({ message: '受保护数据' });
});

4.4 处理预检请求

cors 中间件会自动处理 OPTIONS 预检请求,无需额外代码。如果需要手动处理,可以这样:

app.options('/api/users', cors()); // 启用预检

5. 手动实现 CORS 中间件

如果想了解底层原理,也可以自己编写 CORS 中间件。以下是一个简单的示例:

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  // 处理预检请求
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }
  next();
});

但处理复杂的动态源、凭证等情况时,使用 cors 库更可靠。

6. 处理携带凭证的请求

如果跨域请求需要携带 Cookie 或 HTTP 认证,必须:

  • 服务器设置 Access-Control-Allow-Credentials: true
  • Access-Control-Allow-Origin 不能为 *,必须是明确的源。
  • 客户端在请求中设置 credentials: 'include'(fetch)或 withCredentials: true(XMLHttpRequest)。

示例:

const corsOptions = {
  origin: 'https://frontend.com',
  credentials: true
};

app.use(cors(corsOptions));

客户端 fetch:

fetch('https://api.example.com/data', {
  credentials: 'include'
});

7. 常见问题排查

  • CORS 错误信息:检查浏览器控制台,通常会指示缺少哪个头。
  • 预检请求返回 404:确保服务器正确响应 OPTIONS 请求,且 OPTIONS 路由存在。
  • 带凭证时 Access-Control-Allow-Origin 不能为 *:明确指定源。
  • 多个源:不能直接设置多个源,要么用函数动态返回,要么检查 Origin 后返回对应值。
  • 自定义头未允许:在 Access-Control-Allow-Headers 中包含它们。
  • 请求头暴露:如果客户端需要读取某些响应头(如 Authorization),需在 Access-Control-Expose-Headers 中声明。

8. 与代理服务器的配合

开发环境中,常用 Webpack Dev Server 或 Vite 的代理功能避免 CORS 问题。它们通过在开发服务器转发请求到目标 API,使得浏览器认为请求是同源的。但这仅适用于开发环境,生产环境仍需正确配置 CORS 或使用反向代理。

9. 安全性考虑

  • 谨慎使用 Access-Control-Allow-Origin: *,尤其当 API 需要认证时。
  • 如果允许携带凭证,务必验证 Origin,防止凭证被滥用。
  • 预检请求缓存时间不宜过长,以免策略更新不及时。

10. 完整示例:Express + CORS

const express = require('express');
const cors = require('cors');
const app = express();

// 白名单
const allowedOrigins = ['http://localhost:3000', 'https://myapp.com'];
const corsOptions = {
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  optionsSuccessStatus: 200
};

app.use(cors(corsOptions));

app.use(express.json());

// 模拟用户数据
let users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];

app.get('/api/users', (req, res) => {
  res.json(users);
});

app.post('/api/users', (req, res) => {
  const newUser = { id: users.length + 1, name: req.body.name };
  users.push(newUser);
  res.status(201).json(newUser);
});

app.delete('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id);
  users = users.filter(u => u.id !== id);
  res.status(204).end();
});

// 处理预检请求(cors 已处理,无需额外)
app.listen(3000, () => console.log('Server running on port 3000'));

前端(fetch)示例:

// 注意:如果需要携带 cookie,需设置 credentials: 'include'
fetch('http://localhost:3000/api/users', {
  credentials: 'include'
})
  .then(res => res.json())
  .then(users => console.log(users));

小结

CORS 是浏览器与服务器协作的安全机制。通过正确配置 CORS 头,可以安全地开放跨域访问。在 Express 中,cors 中间件极大地简化了配置。理解 CORS 的工作原理有助于排查跨域问题和保障应用安全。下一章我们将学习 HTTPS 与更多安全实践。