19.02-CORS安全配置

要点

  • 同源策略(Same-Origin Policy)是浏览器的安全基石——不同源的页面默认不能互相读取响应
  • CORS(Cross-Origin Resource Sharing)是服务端告诉浏览器「我允许哪些源来读我的响应」的机制
  • 前后端分离架构下 CORS 是必须配置的——前端 localhost:3000 调后端 api.example.com 就是跨域
  • Access-Control-Allow-Origin: * 开发时图省事,生产环境就是安全隐患——任何网站都能调你的 API
  • 带 Cookie 的跨域请求不能用 *,必须明确指定 origin,且 Access-Control-Allow-Credentials: true
  • Hono 的 hono/cors 中间件封装了 CORS 的复杂协议,但配置参数要理解清楚才能写对

1. 同源策略:为什么需要 CORS

浏览器的同源策略规定——只有协议、域名、端口完全相同的页面才能互相访问资源。

https://example.com       ← 源 A
https://api.example.com   ← 源 B(子域名不同,不同源)
https://example.com:8080  ← 源 C(端口不同,不同源)
http://example.com        ← 源 D(协议不同,不同源)

这四个互相之间都是跨域的。浏览器默认禁止源 A 的 JavaScript 读取源 B 的响应——这是一道安全防线,防止恶意网站偷偷读取你在银行网站的账户信息。

但前后端分离架构下,前端和 API 天然不在同一个源。你需要一种机制让服务端告诉浏览器「我允许这个前端来读我的响应」——这就是 CORS。

2. CORS 的工作原理

CORS 不是加密、不是认证——它是服务端给浏览器的一张白名单:「我允许这些源来读我的响应」。

关键:CORS 是浏览器的安全机制,不是服务端的。服务端没有 CORS 保护——任何程序(curl、Python 脚本、Node.js)都可以直接调你的 API,不受 CORS 限制。 CORS 只约束浏览器里的 JavaScript。

2.1 简单请求与预检请求

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

简单请求:满足以下全部条件的 GET/POST/HEAD 请求——

  • 方法为 GET、HEAD 或 POST
  • Content-Type 只限于 application/x-www-form-urlencodedmultipart/form-datatext/plain
  • 没有自定义 Header(除了浏览器自动加的 AcceptContent-Type 等)

简单请求直接发出,浏览器检查响应里的 CORS 头。如果 origin 不在白名单,浏览器拦截响应,JavaScript 拿不到数据。

非简单请求:不满足简单请求条件的——比如 PUT/DELETE 方法、Content-Type: application/json、带自定义 Header 的请求。

非简单请求会先发一个 OPTIONS 预检请求,问服务端「我能不能用 PUT 方法、带 JSON 来调你?」。服务端回答可以,浏览器才发真正的请求。

浏览器                                    服务端
  │                                        │
  │─── OPTIONS /api/data ─────────────────→│  (预检请求)
  │    Origin: https://app.example.com     │
  │    Access-Control-Request-Method: PUT  │
  │                                        │
  │←── 204 No Content ────────────────────│
  │    Access-Control-Allow-Origin: https://app.example.com
  │    Access-Control-Allow-Methods: PUT, POST, GET
  │    Access-Control-Allow-Headers: Content-Type
  │    Access-Control-Max-Age: 86400
  │                                        │
  │─── PUT /api/data ─────────────────────→│  (真正的请求)
  │    Origin: https://app.example.com     │
  │    Content-Type: application/json      │
  │                                        │
  │←── 200 OK ────────────────────────────│
  │    Access-Control-Allow-Origin: https://app.example.com
  │    { "ok": true }                      │

3. Hono 的 CORS 中间件

hono/cors 中间件封装了 CORS 协议的所有细节:

// src/middleware/cors.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
 
const app = new Hono()
 
// 允许单个源
app.use('/api/*', cors({
  origin: 'https://app.example.com',
}))
 
// 允许多个源
app.use('/api/*', cors({
  origin: [
    'https://app.example.com',
    'https://staging.example.com',
  ],
}))
 
// 允许所有源(开发环境用,生产环境慎用)
app.use('/api/*', cors({
  origin: '*',
}))

3.1 完整的 CORS 配置

生产环境推荐配置:

app.use('/api/*', cors({
  origin: (origin) => {
    // 动态判断 origin 是否合法
    const allowedOrigins = [
      'https://app.example.com',
      'https://staging.example.com',
    ]
    if (allowedOrigins.includes(origin)) {
      return origin
    }
    return ''  // 不在白名单,返回空字符串(浏览器会拦截)
  },
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  exposeHeaders: ['X-Request-Id'],
  maxAge: 86400,  // 预检结果缓存 24 小时
  credentials: true,  // 允许带 Cookie
}))

各参数含义:

参数对应响应头说明
originAccess-Control-Allow-Origin允许的源,* 或具体域名
allowMethodsAccess-Control-Allow-Methods允许的 HTTP 方法
allowHeadersAccess-Control-Allow-Headers允许的请求头
exposeHeadersAccess-Control-Expose-Headers允许前端 JavaScript 读取的响应头
maxAgeAccess-Control-Max-Age预检结果的缓存时间(秒)
credentialsAccess-Control-Allow-Credentials是否允许携带 Cookie

3.2 origin 的函数形式

origin 可以是一个函数,根据请求的 origin 动态返回。这在需要跨子域名或动态判断时很有用:

app.use('/api/*', cors({
  origin: (origin) => {
    // 允许所有 *.example.com 子域名
    if (origin.endsWith('.example.com')) {
      return origin
    }
    return ''
  },
}))

4. 最常见的 CORS 错误

4.1 Access-Control-Allow-Origin: * + Credentials

这是 CORS 里最常踩的坑。origin: '*'credentials: true 不能同时使用——浏览器会直接报错:

Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.

原因:* 意味着「任何源都能读」,但 credentials: true 意味着「会带上用户的 Cookie」。两者结合 = 任何网站都能带 Cookie 调你的 API = 安全隐患。

// 错误:* + credentials
app.use('/api/*', cors({
  origin: '*',
  credentials: true,  // ← 浏览器直接报错
}))
 
// 正确:明确指定 origin + credentials
app.use('/api/*', cors({
  origin: 'https://app.example.com',
  credentials: true,
}))

4.2 忘记暴露自定义响应头

你的 API 可能返回一些自定义响应头(比如 X-Request-IdX-RateLimit-Remaining)。默认情况下,前端 JavaScript 只能读取以下「简单响应头」:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

其他响应头前端读不到——不是服务端没返回,是浏览器不让 JavaScript 读。需要显式 exposeHeaders

app.use('/api/*', cors({
  origin: 'https://app.example.com',
  exposeHeaders: ['X-Request-Id', 'X-RateLimit-Remaining', 'X-RateLimit-Limit'],
}))

4.3 预检请求被认证中间件拦截

很多人配好 CORS 后发现 OPTIONS 请求返回 401——认证中间件把预检请求也拦了。

// 错误:认证中间件在 CORS 之前
app.use('/api/*', authMiddleware)  // ← OPTIONS 请求没有 token,返回 401
app.use('/api/*', cors({ origin: 'https://app.example.com' }))

浏览器发 OPTIONS 预检请求时不带 token(因为还不知道需不需要 token),所以认证中间件应该放在 CORS 之后:

// 正确:CORS 在认证之前
app.use('/api/*', cors({ origin: 'https://app.example.com' }))
app.use('/api/*', authMiddleware)  // ← OPTIONS 请求被 CORS 处理,不会走到这里

Hono 的 hono/cors 中间件会自动处理 OPTIONS 请求——在 CORS 中间件这一层就返回预检响应,不会继续走到认证中间件。

4.4 allowHeaders 漏了 Content-Type

如果你的请求带 Content-Type: application/json(几乎所有 API 请求都是),allowHeaders 里必须包含 Content-Type

app.use('/api/*', cors({
  origin: 'https://app.example.com',
  allowHeaders: ['Content-Type', 'Authorization'],  // ← 别忘了 Content-Type
}))

不写的话,浏览器预检响应里没有 Content-Type 的允许,实际请求发出去会被拦截。

5. 不同场景的 CORS 配置

5.1 纯 API 服务(前后端完全分离)

const app = new Hono()
 
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com']
 
app.use('/api/*', cors({
  origin: (origin) => allowedOrigins.includes(origin) ? origin : '',
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
  credentials: true,
}))

5.2 公开 API(允许第三方调用)

公开 API 不需要 credentials(API Key 放在 Header 里,不是 Cookie),可以用 *

app.use('/api/public/*', cors({
  origin: '*',  // 公开 API,允许任何来源
  allowMethods: ['GET', 'POST'],
  allowHeaders: ['Content-Type', 'X-API-Key'],
  maxAge: 86400,
}))

5.3 需要区分公开和私有接口

// 私有接口:严格 CORS + credentials
app.use('/api/private/*', cors({
  origin: (origin) => isAllowedOrigin(origin) ? origin : '',
  credentials: true,
}))
 
// 公开接口:宽松 CORS,无 credentials
app.use('/api/public/*', cors({
  origin: '*',
}))

5.4 AI 接口的 CORS

AI 接口通常有两种调用方式:

方式一:前端直接调用 LLM(通过你的代理)

// 前端 → 你的 API → LLM API
app.use('/api/ai/*', cors({
  origin: (origin) => isAllowedOrigin(origin) ? origin : '',
  credentials: true,  // 需要带用户的认证 Cookie
}))

方式二:后端调用 LLM,结果通过 SSE 流式返回

流式响应(SSE)的 CORS 跟普通 JSON 响应一样,只要配了 CORS 中间件就行。但注意 SSE 的 Content-Type: text/event-stream 不是简单请求的 Content-Type,会触发预检。确保 allowMethods 包含 GETallowHeaders 包含需要的 Header。

6. CORS 的安全边界

CORS 保护的是浏览器的同源策略。但攻击者不通过浏览器就能绕过 CORS——用 curl、Python、Node.js 都可以直接调你的 API,完全不受 CORS 限制。

# curl 不受 CORS 限制——直接拿到响应
curl -H 'Authorization: Bearer xxx' https://api.example.com/api/data

所以 CORS 不能替代认证。你的 API 安全措施应该是:

  1. CORS → 防止恶意网站通过浏览器偷数据
  2. 认证(JWT / API Key) → 确保请求来自合法用户
  3. 鉴权 → 确保用户只能访问自己有权限的资源
  4. 限流 → 防止滥用

四道防线各司其职。CORS 是第一道(过滤浏览器请求),但绝不是唯一一道。

7. 调试 CORS 问题

CORS 错误是开发中最常见的前后端联调问题之一。几个调试方法:

7.1 看 Network 面板的 OPTIONS 请求

打开浏览器 DevTools → Network 面板,找到 OPTIONS 请求,看:

  1. Request Headers 里的 OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
  2. Response Headers 里的 Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers

对比请求和响应,看哪个字段对不上。

7.2 用 curl 模拟预检请求

curl -i -X OPTIONS https://api.example.com/api/data \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: Content-Type'

看返回的 CORS 头是否符合预期。

7.3 常见错误对照表

浏览器报错原因解决
No 'Access-Control-Allow-Origin' header服务端没返回 CORS 头检查中间件是否正确配置
The value of '*' is not allowed for credentials* + credentials 冲突用具体 origin 替代 *
Response to preflight request doesn't pass access control checkOPTIONS 预检失败检查认证中间件是否在 CORS 之前,OPTIONS 是否被正确拦截
Header X-Custom is not allowedallowHeaders 缺少自定义 HeaderallowHeaders 里加上

8. Hono CORS 中间件的实现细节

如果好奇 hono/cors 内部做了什么——它主要就是处理两件事:

  1. OPTIONS 请求:直接返回 204 + CORS 响应头,不调 next()
  2. 其他请求:调 next() 执行业务逻辑,然后在响应上追加 CORS 头
// 简化的实现逻辑
export function cors(config: CORSConfig) {
  return async (c: Context, next: Next) => {
    // OPTIONS 请求:直接返回预检响应
    if (c.req.method === 'OPTIONS') {
      c.header('Access-Control-Allow-Origin', resolveOrigin(c, config))
      c.header('Access-Control-Allow-Methods', config.allowMethods.join(', '))
      c.header('Access-Control-Allow-Headers', config.allowHeaders.join(', '))
      c.header('Access-Control-Max-Age', String(config.maxAge))
      return c.body(null, 204)
    }
 
    // 其他请求:先执行业务,再追加 CORS 头
    await next()
    c.header('Access-Control-Allow-Origin', resolveOrigin(c, config))
    if (config.credentials) {
      c.header('Access-Control-Allow-Credentials', 'true')
    }
  }
}

理解这个逻辑有助于排查问题——如果你的 OPTIONS 请求走到了业务逻辑(返回 401 或 404),说明 CORS 中间件没配好或者位置不对。

9. 小结

CORS 的本质是服务端给浏览器的白名单——「我允许这些源来读我的响应」。Hono 的 hono/cors 中间件封装了预检请求处理和响应头设置。

最常踩的坑:

  • origin: '*' + credentials: true → 浏览器直接报错
  • 认证中间件在 CORS 之前 → OPTIONS 请求返回 401
  • allowHeaders 漏了 Content-Type → 跨域 JSON 请求被拦截
  • 自定义响应头没 exposeHeaders → 前端 JavaScript 读不到

CORS 只约束浏览器。curl、Python 不受限制——所以 CORS 不能替代认证和鉴权。

下一篇讲 CSRF 防护——CORS 管的是「读」,CSRF 管的是「写」。