diff --git a/README.md b/README.md index 4ccbf88fba6d..1d6bc1d13070 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ A/B testing, canary release, blue-green deployment, limit rate, defense against - [Limit-count](doc/plugins/limit-count.md) - [Limit-concurrency](doc/plugins/limit-conn.md) - Anti-ReDoS(Regular expression Denial of Service): Built-in policies to Anti ReDoS without configuration. + - [CORS](doc/plugins/cors.md) - **OPS friendly** - OpenTracing: [support Apache Skywalking and Zipkin](doc/plugins/zipkin.md) diff --git a/README_CN.md b/README_CN.md index 1f85fbb651e5..1296f26a0159 100644 --- a/README_CN.md +++ b/README_CN.md @@ -90,6 +90,7 @@ A/B 测试、金丝雀发布(灰度发布)、蓝绿部署、限流限速、抵 - [限制请求数](doc/plugins/limit-count-cn.md) - [限制并发](doc/plugins/limit-conn-cn.md) - 防御 ReDoS(正则表达式拒绝服务):内置策略,无需配置即可抵御 ReDoS。 + - [CORS](doc/plugins/cors-cn.md) - **运维友好** - OpenTracing 可观测性: [支持 Apache Skywalking 和 Zipkin](doc/plugins/zipkin-cn.md)。 diff --git a/conf/config.yaml b/conf/config.yaml index e76a3aa47711..a54eb12dd7a7 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -144,6 +144,6 @@ plugins: # plugin list - tcp-logger - proxy-mirror - kafka-logger - + - cors stream_plugins: - mqtt-proxy diff --git a/doc/README.md b/doc/README.md index ad83c5b06182..40da08e2423a 100644 --- a/doc/README.md +++ b/doc/README.md @@ -63,6 +63,7 @@ Plugins * [udp-logger](plugins/udp-logger.md): Log requests to UDP servers. * [proxy-mirror](plugins/proxy-mirror.md): Provides the ability to mirror client requests. * [kafka-logger](plugins/kafka-logger.md): Log requests to External Kafka servers. +* [cors](plugins/cors.md): Enbale cors for you api. Deploy to the Cloud ======= diff --git a/doc/README_CN.md b/doc/README_CN.md index f063486f5450..bf3141400251 100644 --- a/doc/README_CN.md +++ b/doc/README_CN.md @@ -64,3 +64,4 @@ Reference document * [udp-logger](plugins/udp-logger.md): 将请求记录到UDP服务器 * [tcp-logger](plugins/tcp-logger.md): 将请求记录到TCP服务器 * [kafka-logger](plugins/kafka-logger-cn.md): 将请求记录到外部Kafka服务器。 +* [cors](plugins/cors-cn.md): 为你的API启用CORS. diff --git a/doc/plugins/cors-cn.md b/doc/plugins/cors-cn.md new file mode 100644 index 000000000000..413dc95acc85 --- /dev/null +++ b/doc/plugins/cors-cn.md @@ -0,0 +1,93 @@ + + +# [English](cors.md) + +# 目录 + +- [**简介**](#简介) +- [**属性**](#属性) +- [**如何启用**](#如何启用) +- [**测试插件**](#测试插件) +- [**禁用插件**](#禁用插件) + +## 简介 + +`cors` 插件可以让你轻易地为服务端启用 [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) 的返回头。 + +## 属性 + +- `allow_origins`: `可选`,允许跨域访问的 Origin,格式如:`scheme`://`host`:`port`,比如: https://somehost.com:8081。多个值使用 `,` 分割,`allow_credential` 为 `false` 时可以使用 `*` 来表示所有 Origin 均允许通过。你也可以在启用了 `allow_credential` 后使用 `**` 强制允许所有 Origin 都通过,但请注意这样存在安全隐患。默认值为 `*`。 +- `allow_methods`: `可选`,允许跨域访问的 Method,比如: `GET`,`POST`等。多个值使用 `,` 分割,`allow_credential` 为 `false` 时可以使用 `*` 来表示所有 Origin 均允许通过。你也可以在启用了 `allow_credential` 后使用 `**` 强制允许所有 Method 都通过,但请注意这样存在安全隐患。默认值为 `*`。 +- `allow_headers`: `可选`,允许跨域访问时请求方携带哪些非 `CORS规范` 以外的 Header, 多个值使用 `,` 分割。默认值为 `*`。 +- `expose_headers`: `可选`,允许跨域访问时响应方携带哪些非 `CORS规范` 以外的 Header, 多个值使用 `,` 分割。默认值为 `*`。 +- `max_age`: `可选`,浏览器缓存 CORS 结果的最大时间,单位为秒,在这个时间范围内浏览器会复用上一次的检查结果,`-1` 表示不缓存。请注意各个浏览器允许的的最大时间不用,详情请参考 [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#Directives). 默认值为 `5`。 +- `allow_credential`: 是否允许跨域访问的请求方携带凭据(如 Cookie 等),默认值为: `false`。 + +## 如何启用 + +创建 `Route` 或 `Service` 对象,并配置 `cors` 插件。 + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/hello", + "plugins": { + "cors": {} + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +}' +``` + +## 测试插件 + +请求下接口,发现接口已经返回了`CORS`相关的header,代表插件生效 +```shell +curl http://127.0.0.1:9080/hello -v +... +< Server: APISIX web server +< Access-Control-Allow-Origin: * +< Access-Control-Allow-Methods: * +< Access-Control-Allow-Headers: * +< Access-Control-Expose-Headers: * +< Access-Control-Max-Age: 5 +... +``` + +## 禁用插件 + +从配置中移除`cors`插件即可。 +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/hello", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +}' +``` diff --git a/doc/plugins/cors.md b/doc/plugins/cors.md new file mode 100644 index 000000000000..6e6114ee215c --- /dev/null +++ b/doc/plugins/cors.md @@ -0,0 +1,95 @@ + + +# [Chinese](cors-cn.md) + +# Summary + +- [**Description**](#Description) +- [**Attributes**](#Attributes) +- [**How To Enable**](#how-to-Enable) +- [**Test Plugin**](#test-plugin) +- [**Disable Plugin**](#disable-plugin) + +## Description + +`cors` plugin can help you enable [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) easily. + +## Attributes + +- `allow_origins`: `optional`, Which Origins is allowed to enable CORS, format as:`scheme`://`host`:`port`, for example: https://somehost.com:8081. Multiple origin use `,` to split. When `allow_credential` is `false`, you can use `*` to indicate allow all any origin. you alse can allow all any origins forcefully using `**` even already enable `allow_credential`, but it will bring some securiy risks. Default value: `*`. +- `allow_methods`: `optional`, Which Method is allowed to enable CORS, such as: `GET`, `POST` etc. Multiple method use `,` to split. When `allow_credential` is `false`, you can use `*` to indicate allow all any method. You alse can allow all any method forcefully using `**` even already enable `allow_credential`, but it will bring some securiy risks. Default value: `*`. +- `allow_headers`: `optional`, Which headers are allowed to set in requst when access cross-origin resource. Multiple value use `,` to split. Default value: `*`. +- `expose_headers`: `optional`, Which headers are allowed to set in response when access cross-origin resource. Multiple value use `,` to split. Default value: `*`. +- `max_age`: `optional`, 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.Default value: `5`. +- `allow_credential`: Enable request include credentia (such as Cookie etc.), Default avlue: `false`. + +## How To Enable + +Create a `Route` or `Service` object and configure `cors` plugin. + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/hello", + "plugins": { + "cors": {} + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +}' +``` + +## Test Plugin + +curl to server, you will find the headers about `CORS` is be returned, it means plugin is working fine. + +```shell +curl http://127.0.0.1:9080/hello -v +... +< Server: APISIX web server +< Access-Control-Allow-Origin: * +< Access-Control-Allow-Methods: * +< Access-Control-Allow-Headers: * +< Access-Control-Expose-Headers: * +< Access-Control-Max-Age: 5 +... +``` + +## Disable Plugin + +Remove plugin from configuraion. + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/hello", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +}' +``` diff --git a/lua/apisix/plugins/cors.lua b/lua/apisix/plugins/cors.lua new file mode 100644 index 000000000000..208fa71aa1c2 --- /dev/null +++ b/lua/apisix/plugins/cors.lua @@ -0,0 +1,136 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local ngx = ngx +local plugin_name = "cors" +local str_find = string.find +local re_gmatch = ngx.re.gmatch + +local schema = { + type = "object", + properties = { + allow_origins = { + description = + "you can use '*' to allow all origins when no credentials," .. + "'**' to allow forcefully(it will bring some security risks, be carefully)," .. + "multiple origin use ',' to split. default: *.", + type = "string", + default = "*" + }, + allow_methods = { + description = + "you can use '*' to allow all methods when no credentials and '**'," .. + "'**' to allow forcefully(it will bring some security risks, be carefully)," .. + "multiple method use ',' to split. default: *.", + type = "string", + default = "*" + }, + allow_headers = { + description = + "you can use '*' to allow all header when no credentials," .. + "multiple header use ',' to split. default: *.", + type = "string", + default = "*" + }, + expose_headers = { + description = + "you can use '*' to expose all header when no credentials," .. + "multiple header use ',' to split. default: *.", + type = "string", + default = "*" + }, + max_age = { + description = + "maximum number of seconds the results can be cached." .. + "-1 mean no cached,the max value is depend on browser," .. + "more detail plz check MDN. default: 5.", + type = "integer", + default = 5 + }, + allow_credential = { + type = "boolean", + default = false + }, + } +} + +local _M = { + version = 0.1, + priority = 4000, + type = 'auth', + name = plugin_name, + schema = schema, +} + +function _M.check_schema(conf) + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + return true +end + +function _M.header_filter(conf, ctx) + if conf.allow_origins == "**" then + conf.allow_origins = ngx.var.http_origin or '*' + end + if str_find(conf.allow_origins, ",", 1, true) then + local finded = false + local iterator, err = re_gmatch(conf.allow_origins, "([^,]+)", "jiox") + if not iterator then + return 500, {message = "match origins failed", error = err} + end + while true do + local origin, err = iterator() + if err then + return 500, {message = "iterate origins failed", error = err} + end + if not origin then + break + end + + if origin[0] == ngx.var.http_origin then + conf.allow_origins = origin[0] + finded = true + break + end + end + if not finded then + return + end + end + + if conf.allow_methods == "**" then + conf.allow_methods = "GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS,CONNECT,TRACE" + end + + ngx.header["Access-Control-Allow-Origin"] = conf.allow_origins + ngx.header["Access-Control-Allow-Methods"] = conf.allow_methods + ngx.header["Access-Control-Allow-Headers"] = conf.allow_headers + ngx.header["Access-Control-Expose-Headers"] = conf.expose_headers + ngx.header["Access-Control-Max-Age"] = conf.max_age + if conf.allow_credential then + ngx.header["Access-Control-Allow-Credentials"] = true + end + + if ctx.var.request_method == "OPTIONS" then + return 200 + end +end + +return _M diff --git a/t/admin/plugins.t b/t/admin/plugins.t index ee4e0ab3a223..20ee9ed88f7c 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -30,7 +30,7 @@ __DATA__ --- request GET /apisix/admin/plugins/list --- response_body_like eval -qr/\["limit-req","limit-count","limit-conn","key-auth","basic-auth","prometheus","node-status","jwt-auth","zipkin","ip-restriction","grpc-transcode","serverless-pre-function","serverless-post-function","openid-connect","proxy-rewrite","redirect","response-rewrite","fault-injection","udp-logger","wolf-rbac","proxy-cache","tcp-logger","proxy-mirror","kafka-logger"\]/ +qr/\["limit-req","limit-count","limit-conn","key-auth","basic-auth","prometheus","node-status","jwt-auth","zipkin","ip-restriction","grpc-transcode","serverless-pre-function","serverless-post-function","openid-connect","proxy-rewrite","redirect","response-rewrite","fault-injection","udp-logger","wolf-rbac","proxy-cache","tcp-logger","proxy-mirror","kafka-logger","cors"\]/ --- no_error_log [error] diff --git a/t/debug/debug-mode.t b/t/debug/debug-mode.t index fae59849b24b..0acdb8244347 100644 --- a/t/debug/debug-mode.t +++ b/t/debug/debug-mode.t @@ -57,6 +57,7 @@ qr/loaded plugin and sort by priority: [-\d]+ name: [\w-]+/ --- grep_error_log_out loaded plugin and sort by priority: 11000 name: fault-injection loaded plugin and sort by priority: 10000 name: serverless-pre-function +loaded plugin and sort by priority: 4000 name: cors loaded plugin and sort by priority: 3000 name: ip-restriction loaded plugin and sort by priority: 2599 name: openid-connect loaded plugin and sort by priority: 2555 name: wolf-rbac diff --git a/t/plugin/cors.t b/t/plugin/cors.t new file mode 100644 index 000000000000..eed58fb136e1 --- /dev/null +++ b/t/plugin/cors.t @@ -0,0 +1,428 @@ +# +# 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_shuffle(); +no_root_location(); +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.cors") + local ok, err = plugin.check_schema({ + allow_origins = '', + allow_methods = '', + allow_headers = '', + expose_headers = '', + max_age = 600, + allow_credential = true + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 2: wrong value of key +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.cors") + local ok, err = plugin.check_schema({ + allow_origins = '', + allow_methods = '', + allow_headers = '', + expose_headers = '', + max_age = '600', + allow_credential = true + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + + } + } +--- request +GET /t +--- response_body +property "max_age" validation failed: wrong type: expected integer, got string +done +--- no_error_log +[error] + + + +=== TEST 3: add plugin +--- 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": { + } + }, + "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 4: update plugin +--- 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": "**", + "allow_methods": "**", + "allow_headers": "*", + "expose_headers": "*", + "madx_age": 5, + "allow_credential": true + } + }, + "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 5: disable plugin +--- 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": { + }, + "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 6: set route(default) +--- 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, + [[{ + "methods": ["GET"], + "plugins": { + "cors": { + } + }, + "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 7: cors default +--- request +GET /hello HTTP/1.1 +--- 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: 5 +Access-Control-Allow-Credentials: +--- no_error_log +[error] + + + +=== TEST 8: set route(spcific) +--- 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 + } + }, + "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 9: cors spcific +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://sub2.domain.com +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: http://sub2.domain.com +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 10: cors spcific no match orgin +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: http://sub3.domain.com +--- 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 11: set route(force wildcard) +--- 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": "**", + "allow_methods": "**", + "allow_headers": "*", + "expose_headers": "*" + } + }, + "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 12: cors force wildcard +--- request +GET /hello HTTP/1.1 +--- more_headers +Origin: https://sub.domain.com +ExternalHeader1: val +ExternalHeader2: val +ExternalHeader3: val +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: https://sub.domain.com +Access-Control-Allow-Methods: GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS,CONNECT,TRACE +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: +--- no_error_log +[error] + + + +=== TEST 13: cors force wildcard no origin +--- request +GET /hello HTTP/1.1 +--- more_headers +ExternalHeader1: val +ExternalHeader2: val +ExternalHeader3: val +--- response_body +hello world +--- response_headers +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS,CONNECT,TRACE +Access-Control-Allow-Headers: * +Access-Control-Expose-Headers: * +Access-Control-Max-Age: 5 +Access-Control-Allow-Credentials: +--- no_error_log +[error] + + + +=== TEST 14: options return directly +--- request +OPTIONS /hello HTTP/1.1 +--- response_body + +--- no_error_log +[error]