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的行为一致。