CORS 中间件

要点

  • CORS(Cross-Origin Resource Sharing)解决浏览器跨域访问的安全问题
  • Hono 内置的 cors() 中间件处理预检请求和响应头设置
  • origin 配置可以是字符串、字符串数组或函数,动态判断是否允许
  • allowMethodsallowHeadersexposeHeadersmaxAge 控制预检缓存和头信息
  • credentials: true 允许携带 cookie,但 origin 不能是 '*'
  • CORS 中间件必须放在鉴权中间件之前,否则 OPTIONS 请求会被 401 拦截

1. 为什么需要 CORS

浏览器的同源策略禁止网页向不同源的 API 发请求。所谓「同源」指协议、域名、端口完全一致。

https://app.example.com 上的前端访问 https://api.example.com/users,域名不同,属于跨域。浏览器默认会拦截这种请求的响应,除非服务端通过 CORS 响应头明确允许。

服务端需要做的事情:

  1. 响应普通的跨域请求,添加 Access-Control-Allow-Origin
  2. 处理浏览器的 OPTIONS 预检请求,返回允许的方法、头、凭证等信息

Hono 的 cors() 中间件同时处理这两件事。

2. 基本用法

最简单的配置:允许所有来源的跨域请求。

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
 
const app = new Hono()
 
app.use('*', cors())
 
app.get('/api/users', (c) => c.json([{ id: 1, name: 'Alice' }]))
 
export default app

cors() 不传参数时,默认允许所有来源、所有方法、所有头。响应的 CORS 头:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, PUT, POST, DELETE, PATCH
Vary: Origin

这种配置适合开发阶段或完全公开的 API。生产环境通常需要限制来源。

3. 限制 origin

限制允许的来源,传入字符串或数组:

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
 
const app = new Hono()
 
// 单个来源
app.use('*', cors({
  origin: 'https://app.example.com',
}))
 
export default app
// 多个来源
app.use('*', cors({
  origin: [
    'https://app.example.com',
    'https://admin.example.com',
    'https://staging.example.com',
  ],
}))

传入数组时,cors() 会检查请求的 Origin 头是否在数组里。如果在,响应头 Access-Control-Allow-Origin 设为该来源;如果不在,不设该头,浏览器会拒绝响应。

4. 动态 origin

有些场景需要在运行时判断是否允许某个来源,例如从数据库读取租户配置:

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
 
const app = new Hono()
 
app.use('*', cors({
  origin: (origin) => {
    // 允许 *.example.com 下的所有子域
    if (origin.endsWith('.example.com')) {
      return origin
    }
    // 其他来源返回 null,表示不允许
    return null
  },
}))
 
app.get('/api/users', (c) => c.json([]))
 
export default app

origin 函数接收请求的 Origin 头作为参数,返回允许的来源字符串或 null。返回 null 时,响应不会携带 Access-Control-Allow-Origin 头。

也可以用正则表达式匹配:

app.use('*', cors({
  origin: (origin) => {
    return /^https:\/\/.*\.example\.com$/.test(origin) ? origin : null
  },
}))

5. allowMethods 与 allowHeaders

allowMethods 控制预检响应里允许的 HTTP 方法:

app.use('*', cors({
  origin: 'https://app.example.com',
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
}))

默认的 allowMethods 包含 GET, HEAD, PUT, POST, DELETE, PATCH。如果项目里不需要 PATCH,可以显式限制。

allowHeaders 控制预检响应里允许的请求头:

app.use('*', cors({
  origin: 'https://app.example.com',
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
}))

如果前端请求带了 allowHeaders 里没有的头(例如 X-Custom-Header),浏览器会拒绝请求。

默认 allowHeaders 只包含 Content-Type。如果接口需要 Authorization 或其他自定义头,必须显式添加到 allowHeaders

6. exposeHeaders

exposeHeaders 控制浏览器 JS 能读取哪些响应头。默认情况下,JS 只能读取少数几个简单响应头(Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma)。

如果服务端响应里带了 X-Request-IdX-Total-Count 等自定义头,前端 JS 默认读不到。需要通过 exposeHeaders 暴露:

app.use('*', cors({
  origin: 'https://app.example.com',
  exposeHeaders: ['X-Request-Id', 'X-Total-Count', 'X-Response-Time'],
}))
// 前端代码
const res = await fetch('https://api.example.com/users')
const requestId = res.headers.get('X-Request-Id')  // 现在可以读到
const totalCount = res.headers.get('X-Total-Count')  // 现在可以读到

7. maxAge 与预检缓存

浏览器发跨域请求前,如果请求满足一定条件(非简单方法、非简单头、有自定义 Content-Type 等),会先发 OPTIONS 预检请求。预检请求的响应会缓存 maxAge 秒,期间不再发送预检。

app.use('*', cors({
  origin: 'https://app.example.com',
  maxAge: 86400,  // 缓存 24 小时
}))

预检响应头:

Access-Control-Max-Age: 86400

设置合理的 maxAge 可以减少预检请求的数量,降低后端压力。常见值是 86400(24 小时)或 600(10 分钟)。

如果 CORS 配置变更后端没有立即生效,可能是浏览器缓存了旧的预检响应。清除浏览器缓存或等 maxAge 过期即可。

8. credentials 模式

如果跨域请求需要携带 cookie(例如用户登录态),需要同时满足:

  1. 前端 fetch() 设置 credentials: 'include'
  2. 服务端 CORS 配置 credentials: true
  3. 服务端 origin 不能是 '*',必须是具体的来源
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
 
const app = new Hono()
 
app.use('*', cors({
  origin: 'https://app.example.com',  // 不能是 '*'
  credentials: true,
}))
 
app.get('/api/me', (c) => {
  // 读取 cookie
  const session = c.req.header('Cookie')
  return c.json({ session })
})
 
export default app
// 前端代码
await fetch('https://api.example.com/api/me', {
  credentials: 'include',  // 携带 cookie
})

响应头:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

如果 origin 设为 '*'credentials: true,浏览器会报错。这是 W3C 规范的要求——允许任意来源携带凭证访问 API 存在安全风险。

9. CORS 与鉴权的顺序

CORS 中间件必须在鉴权中间件之前注册。原因在前面几节已经提过,这里再展开一下。

浏览器的 OPTIONS 预检请求不带 Authorization 头。如果鉴权中间件在 CORS 之前:

// ❌ 错误顺序
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')
  if (!token) return c.json({ error: 'Unauthorized' }, 401)
  await next()
})
app.use('*', cors())

预检请求的处理流程:

  1. 浏览器发 OPTIONS 预检(不带 Authorization)
  2. 鉴权中间件拦截,返回 401
  3. 响应里没有 CORS 头
  4. 浏览器拒绝发送真正的请求
  5. 前端报 CORS 错误

正确顺序:

// ✅ 正确顺序
app.use('*', cors())
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')
  if (!token) return c.json({ error: 'Unauthorized' }, 401)
  await next()
})

cors() 中间件检测到 OPTIONS 请求时,会直接响应预检结果,不调用 next()。这样鉴权中间件就不会接触到 OPTIONS 请求。

10. 排查 CORS 问题

CORS 问题的典型表现是前端报跨域错误,但后端 API 用 Postman 测试正常。排查方向:

  1. 检查请求方法:如果是 OPTIONS,说明是预检请求。看响应里的 Access-Control-Allow-* 头是否完整
  2. 检查 origin:响应的 Access-Control-Allow-Origin 是否匹配请求的 Origin
  3. 检查 allowHeaders:前端请求带了自定义头(例如 Authorization),服务端 allowHeaders 里有没有包含
  4. 检查 credentials:前端设置了 credentials: 'include',服务端是否配置 credentials: trueorigin 不是 '*'
  5. 检查中间件顺序:CORS 中间件是否在鉴权之前

浏览器 DevTools 的 Network 面板可以查看请求和响应的完整头信息。找到失败的请求,对比 OriginAccess-Control-Allow-Origin 就能定位大部分问题。

总结

CORS 中间件是前后端分离项目的必备组件。Hono 的 cors() 中间件处理了预检请求和响应头设置,配置项覆盖了大部分常见需求。

这一节涉及到的几个配置维度:

  1. origin:字符串、数组或函数,控制允许的来源
  2. allowMethods:预检允许的 HTTP 方法
  3. allowHeaders:预检允许的请求头
  4. exposeHeaders:前端 JS 可读取的响应头
  5. maxAge:预检缓存时间
  6. credentials:是否允许携带 cookie

CORS 问题通常不会在开发阶段暴露(前端和后端同源或禁用跨域检查),上线后才集中出现。理解浏览器跨域机制和中间件配置项,是预防这类问题的基础。

下一篇看 JWT 中间件——token 验证、cookie 与 header 双模式、refresh token 处理。