在用 OpenResty 搭建 API 网关时,CORS 配置看似简单,却很容易踩坑——尤其是当你的 location 块由外部配置文件统一管理时。本文记录我在实际项目中遇到的两堵墙,以及最终用 Lua 阶段钩子彻底解决问题的过程。
#问题背景
服务器运行 OpenResty,对外暴露 API 接口,需要允许以下前端域名跨域访问:
tools.ropean.orgtools.aceapp.dev
要求所有请求头均可通过,并支持携带凭据(Cookie / Authorization)。
#第一堵墙:`add_header` 不能放在 `server` 块顶层
最直觉的写法是把 CORS 头直接加在 server 块里:
1server {
2 set $cors_origin "";
3 if ($http_origin ~* "^https?://(tools\.ropean\.org|tools\.aceapp\.dev)$") {
4 set $cors_origin $http_origin;
5 }
6
7 add_header Access-Control-Allow-Origin $cors_origin always; # ❌
8}启动时直接报错:
1[emerg] "add_header" directive is not allowed herenginx / OpenResty 的 add_header 只在 http、location、if(位于 location 内)等特定上下文中有效,裸放在 server 块顶层是不被允许的。
#第二堵墙:`location /` 冲突,add_header 也不跨 location 继承
既然不能放顶层,那就新建一个 location / 来包裹 CORS 逻辑:
1location / {
2 add_header Access-Control-Allow-Origin $cors_origin always;
3 ...
4}但项目里已经通过 include 引入了外部配置文件:
1include /www/sites/api.ropean.org/proxy/*.conf;而 proxy/root.conf 里已经存在 location /,于是启动时报:
1[emerg] duplicate location "/" in /www/sites/.../proxy/root.conf:1即使没有冲突,nginx 的 add_header 也不会跨 location 继承——父级 location 的 header 对子级或兄弟级 location 完全无效。这意味着就算能新建 location /,它也覆盖不了 proxy/*.conf 里的其他 location。
#解决方案:OpenResty Lua 阶段钩子
OpenResty 内置 lua-nginx-module,提供了请求生命周期中各个阶段的钩子指令。其中两个可以放在 server 级别,天然覆盖所有 location,完美绕开继承问题。
#1. 拦截 OPTIONS 预检请求 — `rewrite_by_lua_block`
浏览器在发送跨域非简单请求前,会先发一个 OPTIONS 预检请求。如果让它流入代理 location,后端通常不处理 OPTIONS,会返回 404 或 405,导致 CORS 失败。
rewrite_by_lua_block 运行在路由阶段之前,可以在请求被分配到任何 location 之前将其拦截:
1rewrite_by_lua_block {
2 if ngx.req.get_method() == "OPTIONS" then
3 local origin = ngx.req.get_headers()["Origin"] or ""
4 if origin:match("^https?://tools%.ropean%.org$")
5 or origin:match("^https?://tools%.aceapp%.dev$") then
6 ngx.header["Access-Control-Allow-Origin"] = origin
7 ngx.header["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
8 ngx.header["Access-Control-Allow-Headers"] = "*"
9 ngx.header["Access-Control-Allow-Credentials"] = "true"
10 ngx.header["Access-Control-Max-Age"] = "86400"
11 ngx.status = 204
12 ngx.exit(204) -- 立即终止,不再往下走
13 end
14 end
15}ngx.exit(204) 会立即终止请求处理,响应直接返回给客户端,不会触及任何代理 location。
#2. 为所有响应注入 CORS 头 — `header_filter_by_lua_block`
对于正常的 GET / POST 等请求,需要在上游响应返回后、发送给客户端之前注入 CORS 头。header_filter_by_lua_block 运行在响应头过滤阶段,同样是 server 级别,覆盖所有 location:
1header_filter_by_lua_block {
2 local origin = ngx.req.get_headers()["Origin"] or ""
3 if origin:match("^https?://tools%.ropean%.org$")
4 or origin:match("^https?://tools%.aceapp%.dev$") then
5 ngx.header["Access-Control-Allow-Origin"] = origin
6 ngx.header["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
7 ngx.header["Access-Control-Allow-Headers"] = "*"
8 ngx.header["Access-Control-Allow-Credentials"] = "true"
9 end
10}无论请求被 proxy/root.conf 还是其他 conf 文件里的哪个 location 处理,这段代码都会执行。
#为什么不用 `Access-Control-Allow-Origin: *`?
两个原因:
*无法与Access-Control-Allow-Credentials: true同时使用,浏览器会直接拒绝。- 动态回填经过校验的
Origin值,才是白名单模式的正确做法,安全性更高,且维护只需改一处。
#最终配置结构
1server {
2 listen 80;
3 server_name api.ropean.org api.aceapp.dev;
4
5 # 代理通用头
6 proxy_set_header Host $host;
7 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
8 proxy_set_header X-Forwarded-Host $server_name;
9 proxy_set_header X-Real-IP $remote_addr;
10 proxy_http_version 1.1;
11 proxy_set_header Upgrade $http_upgrade;
12 proxy_set_header Connection $http_connection;
13
14 access_log /www/sites/api.ropean.org/log/access.log main;
15 error_log /www/sites/api.ropean.org/log/error.log;
16
17 # ✅ 拦截 OPTIONS 预检,server 级别,路由前执行
18 rewrite_by_lua_block {
19 if ngx.req.get_method() == "OPTIONS" then
20 local origin = ngx.req.get_headers()["Origin"] or ""
21 if origin:match("^https?://tools%.ropean%.org$")
22 or origin:match("^https?://tools%.aceapp%.dev$") then
23 ngx.header["Access-Control-Allow-Origin"] = origin
24 ngx.header["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
25 ngx.header["Access-Control-Allow-Headers"] = "*"
26 ngx.header["Access-Control-Allow-Credentials"] = "true"
27 ngx.header["Access-Control-Max-Age"] = "86400"
28 ngx.status = 204
29 ngx.exit(204)
30 end
31 end
32 }
33
34 # ✅ 注入 CORS 响应头,server 级别,覆盖所有 location
35 header_filter_by_lua_block {
36 local origin = ngx.req.get_headers()["Origin"] or ""
37 if origin:match("^https?://tools%.ropean%.org$")
38 or origin:match("^https?://tools%.aceapp%.dev$") then
39 ngx.header["Access-Control-Allow-Origin"] = origin
40 ngx.header["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
41 ngx.header["Access-Control-Allow-Headers"] = "*"
42 ngx.header["Access-Control-Allow-Credentials"] = "true"
43 end
44 }
45
46 # SSL 证书续签验证
47 location ^~ /.well-known/acme-challenge {
48 allow all;
49 root /usr/share/nginx/html;
50 }
51
52 # 原有代理配置,完全不动
53 include /www/sites/api.ropean.org/proxy/*.conf;
54}#三种方案对比
| 方案 | 是否可行 | 原因 |
|---|---|---|
add_header 放 server 块顶层 | ❌ | 该上下文不允许此指令 |
新建 location / + add_header | ❌ | 与已有 location / 冲突 |
父级 location add_header | ❌ | 子/兄弟 location 不继承 |
header_filter_by_lua_block | ✅ | server 级,阶段执行,不受 location 限制 |
rewrite_by_lua_block 拦截 OPTIONS | ✅ | 路由前执行,直接返回,不进代理 |
#总结
nginx 的 add_header 是 location 级别的指令,跨 location 场景下天然无力。当你的 location 由外部文件管理、无法随意修改时,OpenResty 的 Lua 阶段钩子是最干净的解法——不改动任何现有 location,通过生命周期阶段实现横切关注点,这正是 OpenResty 相比纯 nginx 最大的优势所在。