diff --git a/apisix/plugins/cors.lua b/apisix/plugins/cors.lua index 63751732f35f..f0d911f5de92 100644 --- a/apisix/plugins/cors.lua +++ b/apisix/plugins/cors.lua @@ -15,6 +15,7 @@ -- limitations under the License. -- local core = require("apisix.core") +local plugin = require("apisix.plugin") local ngx = ngx local plugin_name = "cors" local str_find = core.string.find @@ -22,12 +23,26 @@ 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 origins_pattern = [[^(\*|\*\*|null|\w+://[^,]+(,\w+://[^,]+)*)$]] local lrucache = core.lrucache.new({ type = "plugin", }) +local metadata_schema = { + type = "object", + properties = { + allow_origins = { + type = "object", + additionalProperties = { + type = "string", + pattern = origins_pattern + } + }, + }, +} + local schema = { type = "object", properties = { @@ -37,7 +52,7 @@ local schema = { "'**' to allow forcefully(it will bring some security risks, be carefully)," .. "multiple origin use ',' to split. default: *.", type = "string", - pattern = [[^(\*|\*\*|null|\w+://[^,]+(,\w+://[^,]+)*)$]], + pattern = origins_pattern, default = "*" }, allow_methods = { @@ -92,6 +107,18 @@ local schema = { minItems = 1, uniqueItems = true, }, + allow_origins_by_metadata = { + type = "array", + description = + "set allowed origins by referencing origins in plugin metadata", + items = { + type = "string", + minLength = 1, + maxLength = 4096, + }, + minItems = 1, + uniqueItems = true, + }, } } @@ -100,15 +127,16 @@ local _M = { priority = 4000, name = plugin_name, schema = schema, + metadata_schema = metadata_schema, } -local function create_multiple_origin_cache(conf) - if not str_find(conf.allow_origins, ",") then +local function create_multiple_origin_cache(allow_origins) + if not str_find(allow_origins, ",") then return nil end local origin_cache = {} - local iterator, err = re_gmatch(conf.allow_origins, "([^,]+)", "jiox") + local iterator, err = re_gmatch(allow_origins, "([^,]+)", "jiox") if not iterator then core.log.error("match origins failed: ", err) return nil @@ -128,7 +156,10 @@ local function create_multiple_origin_cache(conf) end -function _M.check_schema(conf) +function _M.check_schema(conf, schema_type) + if schema_type == core.schema.TYPE_METADATA then + return core.schema.check(metadata_schema, conf) + end local ok, err = core.schema.check(schema, conf) if not ok then return false, err @@ -177,13 +208,23 @@ local function set_cors_headers(conf, ctx) end end -local function process_with_allow_origins(conf, ctx, req_origin) - local allow_origins = conf.allow_origins +local function process_with_allow_origins(allow_origins, ctx, req_origin, + cache_key, cache_version) if allow_origins == "**" then allow_origins = req_origin or '*' end - local multiple_origin, err = core.lrucache.plugin_ctx(lrucache, ctx, nil, - create_multiple_origin_cache, conf) + + local multiple_origin, err + if cache_key and cache_version then + multiple_origin, err = lrucache( + cache_key, cache_version, create_multiple_origin_cache, allow_origins + ) + else + multiple_origin, err = core.lrucache.plugin_ctx( + lrucache, ctx, nil, create_multiple_origin_cache, allow_origins + ) + end + if err then return 500, {message = "get multiple origin cache failed: " .. err} end @@ -225,6 +266,25 @@ local function match_origins(req_origin, allow_origins) return req_origin == allow_origins or allow_origins == '*' end +local function process_with_allow_origins_by_metadata(allow_origins_by_metadata, ctx, req_origin) + if allow_origins_by_metadata == nil then + return + end + + local metadata = plugin.plugin_metadata(plugin_name) + if metadata and metadata.value.allow_origins then + local allow_origins_map = metadata.value.allow_origins + for _, key in ipairs(allow_origins_by_metadata) do + local allow_origins_conf = allow_origins_map[key] + local allow_origins = process_with_allow_origins(allow_origins_conf, ctx, req_origin, + plugin_name .. "#" .. key, metadata.modifiedIndex) + if match_origins(req_origin, allow_origins) then + return req_origin + end + end + end +end + function _M.rewrite(conf, ctx) -- save the original request origin as it may be changed at other phase @@ -239,10 +299,15 @@ function _M.header_filter(conf, ctx) local req_origin = ctx.original_request_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) + allow_origins = process_with_allow_origins(conf.allow_origins, 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 not allow_origins then + allow_origins = process_with_allow_origins_by_metadata( + conf.allow_origins_by_metadata, ctx, req_origin + ) + end if allow_origins then ctx.cors_allow_origins = allow_origins set_cors_headers(conf, ctx) diff --git a/docs/en/latest/plugins/cors.md b/docs/en/latest/plugins/cors.md index 6b92a54adb5d..2c332cffa914 100644 --- a/docs/en/latest/plugins/cors.md +++ b/docs/en/latest/plugins/cors.md @@ -36,12 +36,19 @@ title: cors | 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 | +| allow_origins_by_metadata | array | optional | nil | | Match which origin is allowed to enable CORS by referencing `allow_origins` set in plugin metadata. For example, if `"allow_origins": {"EXAMPLE": "https://example.com"}` is set in metadata, then `["EXAMPLE"]` can be used to match the origin `https://example.com` | > **Tips** > > Please note that `allow_credential` is a very sensitive option, so choose to enable it carefully. After set it be `true`, the default `*` of other parameters will be invalid, you must specify their values explicitly. > When using `**`, you must fully understand that it introduces some security risks, such as CSRF, so make sure that this security level meets your expectations before using it。 +## Metadata + +| Name | Type | Requirement | Default | Valid | Description | +| ----------- | ------ | ----------- | ------- | ----- | ---------------------------------------------------------------------- | +| allow_origins | object | optional | | | A map from origin reference to allowed origins; its key is the reference used by `allow_origins_by_metadata` and its value is a string equivalent to `allow_origins` in plugin attributes | + ## How To Enable Create a `Route` or `Service` object and configure `cors` plugin. diff --git a/docs/zh/latest/plugins/cors.md b/docs/zh/latest/plugins/cors.md index f2c89bd5aa0e..e3cee9d1a25c 100644 --- a/docs/zh/latest/plugins/cors.md +++ b/docs/zh/latest/plugins/cors.md @@ -36,12 +36,19 @@ title: cors | 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的子域名`*`。 | +| allow_origins_by_metadata | array | 可选 | nil | | 通过引用插件元数据的 `allow_origins` 配置允许跨域访问的 Origin。比如当元数据为 `"allow_origins": {"EXAMPLE": "https://example.com"}` 时,配置 `["EXAMPLE"]` 将允许 Origin `https://example.com` 的访问 | > **提示** > > 请注意 `allow_credential` 是一个很敏感的选项,谨慎选择开启。开启之后,其他参数默认的 `*` 将失效,你必须显式指定它们的值。 > 使用 `**` 时要充分理解它引入了一些安全隐患,比如 CSRF,所以确保这样的安全等级符合自己预期再使用。 +## 元数据 + +| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | +| ----------- | ------ | ------ | ----- | ----- | ------------------ | +| allow_origins | object | 可选 | | | 定义允许跨域访问的 Origin;它的键为 `allow_origins_by_metadata` 使用的引用键, 值则为允许跨域访问的 Origin,其语义与 `allow_origins` 相同 | + ## 如何启用 创建 `Route` 或 `Service` 对象,并配置 `cors` 插件。 diff --git a/t/plugin/cors3.t b/t/plugin/cors3.t new file mode 100644 index 000000000000..92210a1a3093 --- /dev/null +++ b/t/plugin/cors3.t @@ -0,0 +1,353 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); +log_level("info"); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->no_error_log && !$block->error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: validate metadata allow_origins +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.cors") + local schema_type = require("apisix.core").schema.TYPE_METADATA + local function validate(val) + local conf = {} + conf.allow_origins = val + return plugin.check_schema(conf, schema_type) + end + + local good = { + key_1 = "*", + key_2 = "**", + key_3 = "null", + key_4 = "http://y.com.uk", + key_5 = "https://x.com", + key_6 = "https://x.com,http://y.com.uk", + key_7 = "https://x.com,http://y.com.uk,http://c.tv", + key_8 = "https://x.com,http://y.com.uk:12000,http://c.tv", + } + local ok, err = validate(good) + if not ok then + ngx.say("failed to validate ", g, ", ", err) + end + + local bad = { + "", + "*a", + "*,http://y.com", + "nulll", + "http//y.com.uk", + "x.com", + "https://x.com,y.com.uk", + "https://x.com,*,https://y.com.uk", + "https://x.com,http://y.com.uk,http:c.tv", + } + for _, b in ipairs(bad) do + local ok, err = validate({key = b}) + if ok then + ngx.say("failed to reject ", b) + end + end + + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 2: set plugin metadata +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/plugin_metadata/cors', + ngx.HTTP_PUT, + [[{ + "allow_origins": { + "key_1": "https://domain.com", + "key_2": "https://sub.domain.com,https://sub2.domain.com", + "key_3": "*" + }, + "inactive_timeout": 1 + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 3: set route (allow_origins_by_metadata 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": "https://test.com", + "allow_origins_by_metadata": ["key_1"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: origin not match +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://foo.example.org +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: +Vary: +Access-Control-Allow-Methods: +Access-Control-Allow-Headers: +Access-Control-Expose-Headers: +Access-Control-Max-Age: +Access-Control-Allow-Credentials: + + + +=== TEST 5: origin matches with allow_origins +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: https://test.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: https://test.com +Vary: Via, Origin +Access-Control-Allow-Methods: * +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: + + + +=== TEST 6: origin matches with allow_origins_by_metadata +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: https://domain.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: https://domain.com +Vary: Via, Origin +Access-Control-Allow-Methods: * +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: + + + +=== TEST 7: set route (multiple allow_origins_by_metadata 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": "https://test.com", + "allow_origins_by_metadata": ["key_1", "key_2"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 8: origin not match +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://foo.example.org +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: +Vary: +Access-Control-Allow-Methods: +Access-Control-Allow-Headers: +Access-Control-Expose-Headers: +Access-Control-Max-Age: +Access-Control-Allow-Credentials: + + + +=== TEST 9: origin matches with first allow_origins_by_metadata +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: https://domain.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: https://domain.com +Vary: Via, Origin +Access-Control-Allow-Methods: * +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: + + + +=== TEST 10: origin matches with second allow_origins_by_metadata +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: https://sub.domain.com +resp-vary: Via +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: https://sub.domain.com +Vary: Via, Origin +Access-Control-Allow-Methods: * +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: + + + +=== TEST 11: set route (wildcard in allow_origins_by_metadata) +--- 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": "https://test.com", + "allow_origins_by_metadata": ["key_3"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 12: origin matches by wildcard +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://foo.example.org +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: http://foo.example.org +Vary: Origin +Access-Control-Allow-Methods: * +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: