跳转至

cors:为 NGINX 提供正确的 CORS,包括预检和 Vary

安装

你可以在任何基于 RHEL 的发行版中安装此模块,包括但不限于:

  • RedHat Enterprise Linux 7、8、9 和 10
  • CentOS 7、8、9
  • AlmaLinux 8、9
  • Rocky Linux 8、9
  • Amazon Linux 2 和 Amazon Linux 2023
dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install nginx-module-cors
yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install nginx-module-cors

在 /etc/nginx/nginx.conf 顶部添加以下内容以启用该模块:

load_module modules/ngx_http_cors_module.so;

本文档介绍 nginx-module-cors v1.0.0,发布于 2026 年 8 月 15 日。


用于 NGINX 的 CORS,处理 add_header 方案会出错的部分。

为什么不直接使用 add_header?

map $http_origin + add_header Access-Control-Allow-* 这个方案是所有人都在照抄的,而它在四个具体方面是有问题的:

  • add_header 是按层级继承或替换的。 一旦任何 location 添加了自己的一个 header,其上设置的所有 add_header——包括你的 CORS header——都会悄无声息地消失。没有任何警告。
  • 预检需要短路处理。 一个 OPTIONS 预检必须以 204 和正确的 header 来响应,而不能到达你的应用。手动实现意味着要用 if 块,而它与 try_files 和 proxy_pass 的交互很糟糕。
  • add_header 只在白名单状态码上生效,除非你传入 always。因此你的错误响应会丢失 CORS header,浏览器会报告一个不透明的 CORS 失败,而不是实际发生的 404 或 502。
  • Vary: Origin 会被遗忘。 当响应依赖于请求的 origin 而你没有声明这一点时,你前面的任何共享缓存或 CDN 都会乐于把一个 origin 的响应提供给另一个 origin。这是一个缓存投毒 bug,也是实际环境中最常见的一个。

本模块正确地完成了这四件事,作为一个 header filter 加上一个 preaccess 阶段的 handler,无需 if 块。

概要

location /api/ {
    cors                on;
    cors_origin         https://app.example.com https://*.staging.example.com;
    cors_methods        GET HEAD POST PUT DELETE;
    cors_headers        Authorization Content-Type;
    cors_expose_headers X-Total-Count;
    cors_credentials    on;
    cors_max_age        86400;

    proxy_pass http://backend;
}

此时一个预检看起来是这样的,并且永远不会到达 backend:

$ curl -i -X OPTIONS https://api.example.com/api/things \
    -H 'Origin: https://app.example.com' \
    -H 'Access-Control-Request-Method: PUT'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin, Access-Control-Request-Method

指令

cors

语法: cors on | off;

默认值: cors off;

上下文: http、server、location、if in location

启用 CORS 处理。其他每个指令都是独立继承的,因此一个嵌套的 location 设置了其中一个指令并不会丢失其余的——这正是与 add_header 的全部区别所在。

cors_origin

语法: cors_origin * | any | <spec> ...;

默认值: cors_origin *;

上下文: http、server、location、if in location

哪些 origin 可以读取该资源。两个保留字和三种 spec 形式:

值 含义
* 发出字面量 Access-Control-Allow-Origin: *。对所有人相同,因此不会添加 Vary: Origin。不能与 cors_credentials on 组合使用。
any 反射到达的任何 Origin。对凭据安全,会发出 Vary: Origin。
https://app.example.com 精确匹配,不区分大小写。
https://*.example.com 通配符子域名。
~^https://(a\|b)\.example\.com$ 正则表达式。~* 表示不区分大小写。

可以列出多个 spec。当其中一个匹配时,该 origin 会被回显。* 和 any 不能与其他值混用。

通配符被刻意设计得很严格:* 必须紧跟在 :// 之后,并且后面必须跟一个 .。https://*example.com 会在启动时被拒绝,而不是静默地匹配 https://evilexample.com。通配符匹配任意数量的前导标签(https://a.b.example.com 匹配 https://*.example.com),但不匹配裸的 apex,也不忽略端口——带端口的 origin 需要精确条目或正则表达式。

Origin: null——沙箱 iframe、data: 文档或 file:// 页面所发送的——在 cors_credentials on 时,只有列表中显式的 null 条目才会匹配它。在这种情况下,any 和正则表达式都不会匹配它。在带凭据的情况下反射 null 会把互联网上每个沙箱 frame 都赋予对该响应的已认证读取权限。

cors_methods

语法: cors_methods * | <method> ...;

默认值: cors_methods GET HEAD POST OPTIONS;

上下文: http、server、location、if in location

在预检时发送的 Access-Control-Allow-Methods 值。* 不能与 cors_credentials on 组合使用。

cors_headers

语法: cors_headers * | any | <name> ...;

默认值: —(省略该 header)

上下文: http、server、location、if in location

在预检时发送的 Access-Control-Allow-Headers 值。any 会原样回显请求的 Access-Control-Request-Headers;当请求没有要求任何 header 时,该 header 会被省略。* 不能与 cors_credentials on 组合使用。

cors_expose_headers

语法: cors_expose_headers <name> ...;

默认值: —(省略该 header)

上下文: http、server、location、if in location

浏览器应使脚本可读取的响应 header,超出安全列表集合之外的部分。在实际响应上发送,而不是在预检上。

cors_credentials

语法: cors_credentials on | off;

默认值: cors_credentials off;

上下文: http、server、location、if in location

发出 Access-Control-Allow-Credentials: true,允许跨源请求携带 cookie 和 HTTP 认证。

CORS 规范禁止将凭据与通配符配对,每个浏览器都会强制执行——因此同时做这两件事的配置就是 CORS 永远无法工作的配置。NGINX 会拒绝启动,而不是让你把它发布出去:

cors_credentials on;
cors_origin      *;      # nginx: [emerg] ... cannot be combined with "cors_origin *"

使用显式列表,或使用 cors_origin any 来反射。请注意,any 加上凭据会让任何网站都能读取该 location 的已认证响应;这是允许的,并且会在启动时记录一条警告。

cors_max_age

语法: cors_max_age <time>;

默认值: —(省略该 header)

上下文: http、server、location、if in location

浏览器可以缓存预检结果多长时间。cors_max_age 0; 是一个有意义的值并且会被发出;省略该指令则省略该 header。

cors_preflight

语法: cors_preflight on | off;

默认值: cors_preflight on;

上下文: http、server、location、if in location

是否在内部应答匹配的预检。当你的应用自己实现了 OPTIONS 而你只想添加响应 header 时,将其关闭。

只有当它确实是一个预检时才会被短路——一个同时携带 Origin 和 Access-Control-Request-Method 的 OPTIONS——并且 origin 匹配。其他一切都会原样通过,因此 WebDAV 和应用级的 OPTIONS 继续正常工作,而来自不允许的 origin 的预检只是收不到 Access-Control-Allow-Origin,这正是让浏览器拒绝它的原因。

由于该 handler 运行在 preaccess 阶段,预检会在 auth_basic、auth_request 和 deny 有机会拒绝它之前被应答。这是刻意的:浏览器从不在预检上发送凭据,因此它前面的任何认证都会完全破坏 CORS。对同一 location 的实际请求仍然正常进行认证。

cors_vary

语法: cors_vary on | off;

默认值: cors_vary on;

上下文: http、server、location、if in location

当响应依赖于请求 origin 时是否发出 Vary: Origin。

保持开启。只要策略是依赖于 origin 的,它就会被发出——包括 origin 未匹配时,以及请求根本没有携带 Origin 时,因为该响应的缓存副本绝不能重放给一个其 origin 本会产生不同 header 的请求。对于静态的 cors_origin *,它被刻意不发出,因为对所有人相同,不会变化。

只有当你确定该 location 前面没有共享缓存,或者你的 CDN 已经以 Origin 作为键时,才将其关闭。

注意事项

  • 响应上已有的 Vary 会被扩展,绝不会被替换:上游发送的 Vary: Accept-Language 会变成 Vary: Accept-Language, Origin,多个上游 Vary 行会被合并为一行。Vary: * 会被保留不动,因为根据 RFC 9110 它已经包含了一切。
  • 有两样东西会在本模块之后添加它们的 Vary,因此会作为单独的字段行出现,而不是被合并进来:同一 location 中的 add_header Vary ...,以及 gzip_vary on 的 Vary: Accept-Encoding。多个 Vary 字段行与一个合并行含义完全相同(RFC 9110 §5.3),每个缓存都能处理,所以这是正确的——只是不够整洁。如果你想把所有内容合并为一行,请加载 nginx-module-compression-vary。
  • 不要在同一 location 中同时用 add_header 设置 CORS header。本模块会替换它找到的任何 Access-Control-Allow-Origin——两个会在每个浏览器中造成硬失败——但其他 header 最终会被重复。
  • Header 会在 304 和 206 响应以及错误上发出。
  • 子请求会被跳过,与 add_header 的行为一致。