diff --git a/apisix/plugins/cors.lua b/apisix/plugins/cors.lua index fc90dc18b69f..4b0b7c4b4a77 100644 --- a/apisix/plugins/cors.lua +++ b/apisix/plugins/cors.lua @@ -19,6 +19,9 @@ local ngx = ngx local plugin_name = "cors" local str_find = core.string.find local re_gmatch = ngx.re.gmatch +local re_compile = require("resty.core.regex").re_match_compile +local re_find = ngx.re.find +local ipairs = ipairs local lrucache = core.lrucache.new({ @@ -73,7 +76,20 @@ local schema = { "if you set this option to 'true', you can not use '*' for other options.", type = "boolean", default = false - } + }, + allow_origins_by_regex = { + type = "array", + description = + "you can use regex to allow specific origins when no credentials," .. + "for example use [.*\\.test.com] to allow a.test.com and b.test.com", + items = { + type = "string", + minLength = 1, + maxLength = 4096, + }, + minItems = 1, + uniqueItems = true, + }, } } @@ -121,6 +137,14 @@ function _M.check_schema(conf) return false, "you can not set '*' for other option when 'allow_credential' is true" end end + if conf.allow_origins_by_regex then + for i, re_rule in ipairs(conf.allow_origins_by_regex) do + local ok, err = re_compile(re_rule, "j") + if not ok then + return false, err + end + end + end return true end @@ -151,17 +175,8 @@ local function set_cors_headers(conf, ctx) end end - -function _M.rewrite(conf, ctx) - if ctx.var.request_method == "OPTIONS" then - return 200 - end -end - - -function _M.header_filter(conf, ctx) +local function process_with_allow_origins(conf, ctx, req_origin) local allow_origins = conf.allow_origins - local req_origin = core.request.header(ctx, "Origin") if allow_origins == "**" then allow_origins = req_origin or '*' end @@ -179,8 +194,55 @@ function _M.header_filter(conf, ctx) end end - ctx.cors_allow_origins = allow_origins - set_cors_headers(conf, ctx) + return allow_origins +end + +local function process_with_allow_origins_by_regex(conf, ctx, req_origin) + if conf.allow_origins_by_regex == nil then + return + end + + if not conf.allow_origins_by_regex_rules_concat then + local allow_origins_by_regex_rules = {} + for i, re_rule in ipairs(conf.allow_origins_by_regex) do + allow_origins_by_regex_rules[i] = re_rule + end + conf.allow_origins_by_regex_rules_concat = core.table.concat( + allow_origins_by_regex_rules, "|") + end + + -- core.log.warn("regex: ", conf.allow_origins_by_regex_rules_concat, "\n ") + local matched = re_find(req_origin, conf.allow_origins_by_regex_rules_concat, "jo") + if matched then + return req_origin + end +end + + +local function match_origins(req_origin, allow_origins) + return req_origin == allow_origins or allow_origins == '*' +end + + +function _M.rewrite(conf, ctx) + if ctx.var.request_method == "OPTIONS" then + return 200 + end +end + + +function _M.header_filter(conf, ctx) + local req_origin = core.request.header(ctx, "Origin") + -- Try allow_origins first, if mismatched, try allow_origins_by_regex. + local allow_origins + allow_origins = process_with_allow_origins(conf, ctx, req_origin) + if not match_origins(req_origin, allow_origins) then + allow_origins = process_with_allow_origins_by_regex(conf, ctx, req_origin) + end + if allow_origins then + ctx.cors_allow_origins = allow_origins + set_cors_headers(conf, ctx) + end end return _M diff --git a/docs/en/latest/plugins/cors.md b/docs/en/latest/plugins/cors.md index bd3ee75eb4a3..2268c1602793 100644 --- a/docs/en/latest/plugins/cors.md +++ b/docs/en/latest/plugins/cors.md @@ -43,6 +43,7 @@ title: cors | expose_headers | string | optional | "*" | | Which headers are allowed to set in response when access cross-origin resource. Multiple value use `,` to split. | | max_age | integer | optional | 5 | | Maximum number of seconds the results can be cached.. Within this time range, the browser will reuse the last check result. `-1` means no cache. Please note that the maximum value is depended on browser, please refer to [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#Directives) for details. | | allow_credential | boolean | optional | false | | Enable request include credential (such as Cookie etc.). According to CORS specification, if you set this option to `true`, you can not use '*' for other options. | +| allow_origins_by_regex | array | optional | nil | | Use regex expressions to match which origin is allowed to enable CORS, for example, [".*\.test.com"] can use to match all subdomain of test.com | > **Tips** > diff --git a/docs/zh/latest/plugins/cors.md b/docs/zh/latest/plugins/cors.md index cef32dea454c..672ba11fe008 100644 --- a/docs/zh/latest/plugins/cors.md +++ b/docs/zh/latest/plugins/cors.md @@ -43,6 +43,7 @@ title: cors | expose_headers | string | 可选 | "*" | | 允许跨域访问时响应方携带哪些非 `CORS规范` 以外的 Header, 多个值使用 `,` 分割。 | | max_age | integer | 可选 | 5 | | 浏览器缓存 CORS 结果的最大时间,单位为秒,在这个时间范围内浏览器会复用上一次的检查结果,`-1` 表示不缓存。请注意各个浏览器允许的的最大时间不同,详情请参考 [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#Directives)。 | | allow_credential | boolean | 可选 | false | | 是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 `true`,那么将不能在其他选项中使用 `*`。 | +| allow_origins_by_regex | array | 可选 | nil | | 使用正则表达式数组来匹配允许跨域访问的 Origin, 如[".*\.test.com"] 可以匹配任何test.com的子域名`*`。 | > **提示** > diff --git a/t/plugin/cors.t b/t/plugin/cors.t index ec6166ce6e6c..8c561e7d9be8 100644 --- a/t/plugin/cors.t +++ b/t/plugin/cors.t @@ -757,4 +757,173 @@ GET /t --- response_body eval qr/failed to check the configuration of plugin cors err: you can not/ --- no_error_log + + + +=== TEST 28: set route (regex specified) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "cors": { + "allow_origins": "http://sub.domain.com,http://sub2.domain.com", + "allow_methods": "GET,POST", + "allow_headers": "headr1,headr2", + "expose_headers": "ex-headr1,ex-headr2", + "max_age": 50, + "allow_credential": true, + "allow_origins_by_regex":[".*\\.test.com"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 29: regex specified +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://a.test.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: http://a.test.com +Vary: Via, Origin +Access-Control-Allow-Methods: GET,POST +Access-Control-Allow-Headers: headr1,headr2 +Access-Control-Expose-Headers: ex-headr1,ex-headr2 +Access-Control-Max-Age: 50 +Access-Control-Allow-Credentials: true +--- no_error_log +[error] + + + +=== TEST 30: regex specified not match +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://a.test2.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: +Access-Control-Allow-Methods: +Access-Control-Allow-Headers: +Access-Control-Expose-Headers: +Access-Control-Max-Age: +Access-Control-Allow-Credentials: +--- no_error_log +[error] + + + +=== TEST 31: set route (multiple regex specified ) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "cors": { + "allow_origins": "http://sub.domain.com,http://sub2.domain.com", + "allow_methods": "GET,POST", + "allow_headers": "headr1,headr2", + "expose_headers": "ex-headr1,ex-headr2", + "max_age": 50, + "allow_credential": true, + "allow_origins_by_regex":[".*\\.test.com",".*\\.example.org"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 32: multiple regex specified match +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://foo.example.org +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: http://foo.example.org +Vary: Via, Origin +Access-Control-Allow-Methods: GET,POST +Access-Control-Allow-Headers: headr1,headr2 +Access-Control-Expose-Headers: ex-headr1,ex-headr2 +Access-Control-Max-Age: 50 +Access-Control-Allow-Credentials: true +--- no_error_log +[error] + + + +=== TEST 33: multiple regex specified not match +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://foo.example.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: +Access-Control-Allow-Methods: +Access-Control-Allow-Headers: +Access-Control-Expose-Headers: +Access-Control-Max-Age: +Access-Control-Allow-Credentials: +--- no_error_log [error]