Plugin cors (#349)

This commit is contained in:
Jun
2023-05-25 19:21:21 +08:00
committed by GitHub
parent 44637c2449
commit 176ddc6963
9 changed files with 1451 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
# 功能说明
`cors` 插件可以为服务端启用 CORSCross-Origin Resource Sharing跨域资源共享的返回 http 响应头。
# 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-----------------------|-----------------|-------|---------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| allow_origins | array of string | 选填 | * | 允许跨域访问的 Origin格式为 scheme://host:port示例如 http://example.com:8081。当 allow_credentials 为 false 时,可以使用 * 来表示允许所有 Origin 通过 |
| allow_origin_patterns | array of string | 选填 | - | 允许跨域访问的 Origin 模式匹配, 用 * 匹配域名或者端口, <br/>比如 http://*.example.com -- 匹配域名, http://*.example.com:[8080,9090] -- 匹配域名和指定端口, http://*.example.com:[*] -- 匹配域名和所有端口。单独 * 表示匹配所有域名和端口 |
| allow_methods | array of string | 选填 | GET, PUT, POST, DELETE, PATCH, OPTIONS | 允许跨域访问的 Method比如GETPOST 等。可以使用 * 来表示允许所有 Method。 |
| allow_headers | array of string | 选填 | DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With<br/>If-Modified-Since,Cache-Control,Content-Type,Authorization | 允许跨域访问时请求方携带哪些非 CORS 规范以外的 Header。可以使用 * 来表示允许任意 Header。 |
| expose_headers | array of string | 选填 | - | 允许跨域访问时响应方携带哪些非 CORS 规范以外的 Header。可以使用 * 来表示允许任意 Header。 |
| allow_credentials | bool | 选填 | false | 是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 true在 allow_origins 不能使用 * 替换成使用 allow_origin_patterns * |
| max_age | number | 选填 | 86400秒 | 浏览器缓存 CORS 结果的最大时间,单位为秒。<br/>在这个时间范围内,浏览器会复用上一次的检查结果 |
> 注意
> * allow_credentials 是一个很敏感的选项请谨慎开启。开启之后allow_credentials 和 allow_origins 为 * 不能同时使用,同时设置时, allow_origins 值为 "*" 生效。
> * allow_origins 和 allow_origin_patterns 可以同时设置, 先检查 allow_origins 是否匹配,然后再检查 allow_origin_patterns 是否匹配
> * 非法 CORS 请求, HTTP 状态码返回是 403 返回体内容为 "Invalid CORS request"
# 配置示例
## 允许所有跨域访问, 不允许请求方携带凭据
```yaml
allow_origins:
- '*'
allow_methods:
- '*'
allow_headers:
- '*'
expose_headers:
- '*'
allow_credentials: false
max_age: 7200
```
## 允许所有跨域访问,同时允许请求方携带凭据
```yaml
allow_origin_patterns:
- '*'
allow_methods:
- '*'
allow_headers:
- '*'
expose_headers:
- '*'
allow_credentials: true
max_age: 7200
```
## 允许特定子域,特定方法,特定请求头跨域访问,同时允许请求方携带凭据
```yaml
allow_origin_patterns:
- http://*.example.com
- http://*.example.org:[8080,9090]
allow_methods:
- GET
- PUT
- POST
- DELETE
allow_headers:
- Token
- Content-Type
- Authorization
expose_headers:
- '*'
allow_credentials: true
max_age: 7200
```
# 测试
## 测试配置
```yaml
apiVersion: networking.higress.io/v1
kind: McpBridge
metadata:
name: mcp-cors-httpbin
namespace: higress-system
spec:
registries:
- domain: httpbin.org
name: httpbin
port: 80
type: dns
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
higress.io/destination: httpbin.dns
higress.io/upstream-vhost: "httpbin.org"
higress.io/backend-protocol: HTTP
name: ingress-cors-httpbin
namespace: higress-system
spec:
ingressClassName: higress
rules:
- host: httpbin.example.com
http:
paths:
- backend:
resource:
apiGroup: networking.higress.io
kind: McpBridge
name: mcp-cors-httpbin
path: /
pathType: Prefix
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: wasm-cors-httpbin
namespace: higress-system
spec:
defaultConfigDisable: true
matchRules:
- config:
allow_origins:
- http://httpbin.example.net
allow_origin_patterns:
- http://*.example.com:[*]
- http://*.example.org:[9090,8080]
allow_methods:
- GET
- POST
- PATCH
allow_headers:
- Content-Type
- Token
- Authorization
expose_headers:
- X-Custom-Header
- X-Env-UTM
allow_credentials: true
max_age: 3600
configDisable: false
ingress:
- ingress-cors-httpbin
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cors:1.0.0
imagePullPolicy: Always
```
## 请求测试
### 简单请求
```shell
curl -v -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" http://127.0.0.1/anything/get\?foo\=1
< HTTP/1.1 200 OK
> x-cors-version: 1.0.0
> access-control-allow-origin: http://httpbin2.example.org:9090
> access-control-expose-headers: X-Custom-Header,X-Env-UTM
> access-control-allow-credentials: true
```
### 预检请求
```shell
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: Content-Type, Token" http://127.0.0.1/anything/get\?foo\=1
< HTTP/1.1 200 OK
< x-cors-version: 1.0.0
< access-control-allow-origin: http://httpbin2.example.org:9090
< access-control-allow-methods: GET,POST,PATCH
< access-control-allow-headers: Content-Type,Token,Authorization
< access-control-expose-headers: X-Custom-Header,X-Env-UTM
< access-control-allow-credentials: true
< access-control-max-age: 3600
< date: Tue, 23 May 2023 11:41:28 GMT
< server: istio-envoy
< content-length: 0
<
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0
```
### 非法 CORS Origin 预检请求
```shell
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: GET" http://127.0.0.1/anything/get\?foo\=1
HTTP/1.1 403 Forbidden
< content-length: 70
< content-type: text/plain
< x-cors-version: 1.0.0
< date: Tue, 23 May 2023 11:27:01 GMT
< server: istio-envoy
<
* Connection #0 to host 127.0.0.1 left intact
Invalid CORS request
```
### 非法 CORS Method 预检请求
```shell
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: DELETE" http://127.0.0.1/anything/get\?foo\=1
< HTTP/1.1 403 Forbidden
< content-length: 49
< content-type: text/plain
< x-cors-version: 1.0.0
< date: Tue, 23 May 2023 11:28:51 GMT
< server: istio-envoy
<
* Connection #0 to host 127.0.0.1 left intact
Invalid CORS request
```
### 非法 CORS Header 预检请求
```shell
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: TokenView" http://127.0.0.1/anything/get\?foo\=1
< HTTP/1.1 403 Forbidden
< content-length: 52
< content-type: text/plain
< x-cors-version: 1.0.0
< date: Tue, 23 May 2023 11:31:03 GMT
< server: istio-envoy
<
* Connection #0 to host 127.0.0.1 left intact
Invalid CORS request
```
# 参考文档
- https://www.ruanyifeng.com/blog/2016/04/cors.html
- https://fetch.spec.whatwg.org/#http-cors-protocol

View File

@@ -0,0 +1 @@
1.0.0

View File

@@ -0,0 +1,457 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed 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.
package config
import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
)
const (
defaultMatchAll = "*"
defaultAllowMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS"
defaultAllAllowMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT"
defaultAllowHeaders = "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
"If-Modified-Since,Cache-Control,Content-Type,Authorization"
defaultMaxAge = 86400
protocolHttpName = "http"
protocolHttpPort = "80"
protocolHttpsName = "https"
protocolHttpsPort = "443"
HeaderPluginDebug = "X-Cors-Version"
HeaderPluginTrace = "X-Cors-Trace"
HeaderOrigin = "Origin"
HttpMethodOptions = "OPTIONS"
HeaderAccessControlAllowOrigin = "Access-Control-Allow-Origin"
HeaderAccessControlAllowMethods = "Access-Control-Allow-Methods"
HeaderAccessControlAllowHeaders = "Access-Control-Allow-Headers"
HeaderAccessControlAllowCredentials = "Access-Control-Allow-Credentials"
HeaderAccessControlExposeHeaders = "Access-Control-Expose-Headers"
HeaderAccessControlMaxAge = "Access-Control-Max-Age"
HeaderControlRequestMethod = "Access-Control-Request-Method"
HeaderControlRequestHeaders = "Access-Control-Request-Headers"
HttpContextKey = "CORS"
)
var portsRegex = regexp.MustCompile(`(.*):\[(\*|\d+(,\d+)*)]`)
type OriginPattern struct {
declaredPattern string
pattern *regexp.Regexp
patternValue string
}
func newOriginPatternFromString(declaredPattern string) OriginPattern {
declaredPattern = strings.ToLower(strings.TrimSuffix(declaredPattern, "/"))
matches := portsRegex.FindAllStringSubmatch(declaredPattern, -1)
portList := ""
patternValue := declaredPattern
if len(matches) > 0 {
patternValue = matches[0][1]
portList = matches[0][2]
}
patternValue = "\\Q" + patternValue + "\\E"
patternValue = strings.ReplaceAll(patternValue, "*", "\\E.*\\Q")
if len(portList) > 0 {
if portList == defaultMatchAll {
patternValue += "(:\\d+)?"
} else {
patternValue += ":(" + strings.ReplaceAll(portList, ",", "|") + ")"
}
}
return OriginPattern{
declaredPattern: declaredPattern,
patternValue: patternValue,
pattern: regexp.MustCompile(patternValue),
}
}
type CorsConfig struct {
// allowOrigins A list of origins for which cross-origin requests are allowed.
// Be a specific domain, e.g. "https://example.com", or the CORS defined special value "*" for all origins.
// Keep in mind however that the CORS spec does not allow "*" when allowCredentials is set to true, using allowOriginPatterns instead
// By default, it is set to "*" when allowOriginPatterns is not set too.
allowOrigins []string
// allowOriginPatterns A list of origin patterns for which cross-origin requests are allowed
// origins patterns with "*" anywhere in the host name in addition to port
// lists Examples:
// https://*.example.com -- domains ending with example.com
// https://*.example.com:[8080,9090] -- domains ending with example.com on port 8080 or port 9090
// https://*.example.com:[*] -- domains ending with example.com on any port, including the default port
// The special value "*" allows all origins
// By default, it is not set.
allowOriginPatterns []OriginPattern
// allowMethods A list of method for which cross-origin requests are allowed
// The special value "*" allows all methods.
// By default, it is set to "GET, PUT, POST, DELETE, PATCH, OPTIONS".
allowMethods []string
// allowHeaders A list of headers that a pre-flight request can list as allowed
// The special value "*" allows actual requests to send any header
// By default, it is set to "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization"
allowHeaders []string
// exposeHeaders A list of response headers an actual response might have and can be exposed.
// The special value "*" allows all headers to be exposed for non-credentialed requests.
// By default, it is not set
exposeHeaders []string
// allowCredentials Whether user credentials are supported.
// By default, it is not set (i.e. user credentials are not supported).
allowCredentials bool
// maxAge Configure how long, in seconds, the response from a pre-flight request can be cached by clients.
// By default, it is set to 86400 seconds.
maxAge int
}
type HttpCorsContext struct {
IsValid bool
ValidReason string
IsPreFlight bool
IsCorsRequest bool
AllowOrigin string
AllowMethods string
AllowHeaders string
ExposeHeaders string
AllowCredentials bool
MaxAge int
}
func (c *CorsConfig) GetVersion() string {
return "1.0.0"
}
func (c *CorsConfig) FillDefaultValues() {
if len(c.allowOrigins) == 0 && len(c.allowOriginPatterns) == 0 && c.allowCredentials == false {
c.allowOrigins = []string{defaultMatchAll}
}
if len(c.allowHeaders) == 0 {
c.allowHeaders = []string{defaultAllowHeaders}
}
if len(c.allowMethods) == 0 {
c.allowMethods = strings.Split(defaultAllowMethods, "")
}
if c.maxAge == 0 {
c.maxAge = defaultMaxAge
}
}
func (c *CorsConfig) AddAllowOrigin(origin string) error {
origin = strings.TrimSpace(origin)
if len(origin) == 0 {
return nil
}
if origin == defaultMatchAll {
if c.allowCredentials == true {
return errors.New("can't set origin to * when allowCredentials is true, use AllowOriginPatterns instead")
}
c.allowOrigins = []string{defaultMatchAll}
return nil
}
c.allowOrigins = append(c.allowOrigins, strings.TrimSuffix(origin, "/"))
return nil
}
func (c *CorsConfig) AddAllowHeader(header string) {
header = strings.TrimSpace(header)
if len(header) == 0 {
return
}
if header == defaultMatchAll {
c.allowHeaders = []string{defaultMatchAll}
return
}
c.allowHeaders = append(c.allowHeaders, header)
}
func (c *CorsConfig) AddAllowMethod(method string) {
method = strings.TrimSpace(method)
if len(method) == 0 {
return
}
if method == defaultMatchAll {
c.allowMethods = []string{defaultMatchAll}
return
}
c.allowMethods = append(c.allowMethods, strings.ToUpper(method))
}
func (c *CorsConfig) AddExposeHeader(header string) {
header = strings.TrimSpace(header)
if len(header) == 0 {
return
}
if header == defaultMatchAll {
c.exposeHeaders = []string{defaultMatchAll}
return
}
c.exposeHeaders = append(c.exposeHeaders, header)
}
func (c *CorsConfig) AddAllowOriginPattern(pattern string) {
pattern = strings.TrimSpace(pattern)
if len(pattern) == 0 {
return
}
originPattern := newOriginPatternFromString(pattern)
c.allowOriginPatterns = append(c.allowOriginPatterns, originPattern)
}
func (c *CorsConfig) SetAllowCredentials(allowCredentials bool) error {
if allowCredentials && len(c.allowOrigins) > 0 && c.allowOrigins[0] == defaultMatchAll {
return errors.New("can't set allowCredentials to true when allowOrigin is *")
}
c.allowCredentials = allowCredentials
return nil
}
func (c *CorsConfig) SetMaxAge(maxAge int) {
if maxAge <= 0 {
c.maxAge = defaultMaxAge
} else {
c.maxAge = maxAge
}
}
func (c *CorsConfig) Process(scheme string, host string, method string, headers [][2]string) (HttpCorsContext, error) {
scheme = strings.ToLower(strings.TrimSpace(scheme))
host = strings.ToLower(strings.TrimSpace(host))
method = strings.ToLower(strings.TrimSpace(method))
// Init httpCorsContext with default values
httpCorsContext := HttpCorsContext{IsValid: true, IsPreFlight: false, IsCorsRequest: false, AllowCredentials: false, MaxAge: 0}
// Get request origin, controlRequestMethod, controlRequestHeaders from http headers
origin := ""
controlRequestMethod := ""
controlRequestHeaders := ""
for _, header := range headers {
key := header[0]
// Get origin
if strings.ToLower(key) == strings.ToLower(HeaderOrigin) {
origin = strings.TrimSuffix(strings.TrimSpace(header[1]), "/")
}
// Get control request method & headers
if strings.ToLower(key) == strings.ToLower(HeaderControlRequestMethod) {
controlRequestMethod = strings.TrimSpace(header[1])
}
if strings.ToLower(key) == strings.ToLower(HeaderControlRequestHeaders) {
controlRequestHeaders = strings.TrimSpace(header[1])
}
}
// Parse if request is CORS and pre-flight request.
isCorsRequest := c.isCorsRequest(scheme, host, origin)
isPreFlight := c.isPreFlight(origin, method, controlRequestMethod)
httpCorsContext.IsCorsRequest = isCorsRequest
httpCorsContext.IsPreFlight = isPreFlight
// Skip when it is not CORS request
if !isCorsRequest {
httpCorsContext.IsValid = true
return httpCorsContext, nil
}
// Check origin
allowOrigin, originOk := c.checkOrigin(origin)
if !originOk {
// Reject: origin is not allowed
httpCorsContext.IsValid = false
httpCorsContext.ValidReason = fmt.Sprintf("origin:%s is not allowed", origin)
return httpCorsContext, nil
}
// Check method
requestMethod := method
if isPreFlight {
requestMethod = controlRequestMethod
}
allowMethods, methodOk := c.checkMethods(requestMethod)
if !methodOk {
// Reject: method is not allowed
httpCorsContext.IsValid = false
httpCorsContext.ValidReason = fmt.Sprintf("method:%s is not allowed", requestMethod)
return httpCorsContext, nil
}
// Check headers
allowHeaders, headerOK := c.checkHeaders(controlRequestHeaders)
if isPreFlight && !headerOK {
// Reject: headers are not allowed
httpCorsContext.IsValid = false
httpCorsContext.ValidReason = "Reject: headers are not allowed"
return httpCorsContext, nil
}
// Store result in httpCorsContext and return it.
httpCorsContext.AllowOrigin = allowOrigin
if isPreFlight {
httpCorsContext.AllowMethods = allowMethods
}
if isPreFlight && len(allowHeaders) > 0 {
httpCorsContext.AllowHeaders = allowHeaders
}
if isPreFlight && c.maxAge > 0 {
httpCorsContext.MaxAge = c.maxAge
}
if len(c.exposeHeaders) > 0 {
httpCorsContext.ExposeHeaders = strings.Join(c.exposeHeaders, ",")
}
httpCorsContext.AllowCredentials = c.allowCredentials
return httpCorsContext, nil
}
func (c *CorsConfig) checkOrigin(origin string) (string, bool) {
origin = strings.TrimSpace(origin)
if len(origin) == 0 {
return "", false
}
matchOrigin := strings.ToLower(origin)
// Check exact match
for _, allowOrigin := range c.allowOrigins {
if allowOrigin == defaultMatchAll {
return origin, true
}
if strings.ToLower(allowOrigin) == matchOrigin {
return origin, true
}
}
// Check pattern match
for _, allowOriginPattern := range c.allowOriginPatterns {
if allowOriginPattern.declaredPattern == defaultMatchAll || allowOriginPattern.pattern.MatchString(matchOrigin) {
return origin, true
}
}
return "", false
}
func (c *CorsConfig) checkHeaders(requestHeaders string) (string, bool) {
if len(c.allowHeaders) == 0 {
return "", false
}
if len(requestHeaders) == 0 {
return strings.Join(c.allowHeaders, ","), true
}
// Return all request headers when allowHeaders contains *
if c.allowHeaders[0] == defaultMatchAll {
return requestHeaders, true
}
checkHeaders := strings.Split(requestHeaders, ",")
// Each request header should be existed in allowHeaders configuration
for _, h := range checkHeaders {
isExist := false
for _, allowHeader := range c.allowHeaders {
if strings.ToLower(h) == strings.ToLower(allowHeader) {
isExist = true
break
}
}
if !isExist {
return "", false
}
}
return strings.Join(c.allowHeaders, ","), true
}
func (c *CorsConfig) checkMethods(requestMethod string) (string, bool) {
if len(requestMethod) == 0 {
return "", false
}
// Find method existed in allowMethods configuration
for _, method := range c.allowMethods {
if method == defaultMatchAll {
return defaultAllAllowMethods, true
}
if strings.ToLower(method) == strings.ToLower(requestMethod) {
return strings.Join(c.allowMethods, ","), true
}
}
return "", false
}
func (c *CorsConfig) isPreFlight(origin, method, controllerRequestMethod string) bool {
return len(origin) > 0 && strings.ToLower(method) == strings.ToLower(HttpMethodOptions) && len(controllerRequestMethod) > 0
}
func (c *CorsConfig) isCorsRequest(scheme, host, origin string) bool {
if len(origin) == 0 {
return false
}
url, err := url.Parse(strings.TrimSpace(origin))
if err != nil {
return false
}
// Check scheme
if strings.ToLower(scheme) != strings.ToLower(url.Scheme) {
return true
}
// Check host and port
port := ""
originPort := ""
originHost := ""
host, port = c.getHostAndPort(scheme, host)
originHost, originPort = c.getHostAndPort(url.Scheme, url.Host)
if host != originHost || port != originPort {
return true
}
return false
}
func (c *CorsConfig) getHostAndPort(scheme string, host string) (string, string) {
// Get host and port
scheme = strings.ToLower(scheme)
host = strings.ToLower(host)
port := ""
hosts := strings.Split(host, ":")
if len(hosts) > 1 {
host = hosts[0]
port = hosts[1]
}
// Get default port according scheme
if len(port) == 0 && scheme == protocolHttpName {
port = protocolHttpPort
}
if len(port) == 0 && scheme == protocolHttpsName {
port = protocolHttpsPort
}
return host, port
}

View File

@@ -0,0 +1,408 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed 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.
package config
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestCorsConfig_getHostAndPort(t *testing.T) {
tests := []struct {
name string
scheme string
host string
wantHost string
wantPort string
}{
{
name: "http without port",
scheme: "http",
host: "http.example.com",
wantHost: "http.example.com",
wantPort: "80",
},
{
name: "https without port",
scheme: "https",
host: "http.example.com",
wantHost: "http.example.com",
wantPort: "443",
},
{
name: "http with port and case insensitive",
scheme: "hTTp",
host: "hTTp.Example.com:8080",
wantHost: "http.example.com",
wantPort: "8080",
},
{
name: "https with port and case insensitive",
scheme: "hTTps",
host: "hTTp.Example.com:8080",
wantHost: "http.example.com",
wantPort: "8080",
},
{
name: "protocal is not http",
scheme: "wss",
host: "hTTp.Example.com",
wantHost: "http.example.com",
wantPort: "",
},
{
name: "protocal is not http",
scheme: "wss",
host: "hTTp.Example.com:8080",
wantHost: "http.example.com",
wantPort: "8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &CorsConfig{}
host, port := c.getHostAndPort(tt.scheme, tt.host)
assert.Equal(t, tt.wantHost, host)
assert.Equal(t, tt.wantPort, port)
})
}
}
func TestCorsConfig_isCorsRequest(t *testing.T) {
tests := []struct {
name string
scheme string
host string
origin string
want bool
}{
{
name: "blank origin",
scheme: "http",
host: "httpbin.example.com",
origin: "",
want: false,
},
{
name: "normal equal case with space and case ",
scheme: "http",
host: "httpbin.example.com",
origin: "http://hTTPbin.Example.com",
want: false,
},
{
name: "cors request with port diff",
scheme: "http",
host: "httpbin.example.com",
origin: " http://httpbin.example.com:8080 ",
want: true,
},
{
name: "cors request with scheme diff",
scheme: "http",
host: "httpbin.example.com",
origin: " https://HTTPpbin.Example.com ",
want: true,
},
{
name: "cors request with host diff",
scheme: "http",
host: "httpbin.example.com",
origin: " http://HTTPpbin.Example.org ",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &CorsConfig{}
assert.Equalf(t, tt.want, c.isCorsRequest(tt.scheme, tt.host, tt.origin), "isCorsRequest(%v, %v, %v)", tt.scheme, tt.host, tt.origin)
})
}
}
func TestCorsConfig_isPreFlight(t *testing.T) {
tests := []struct {
name string
origin string
method string
controllerRequestMethod string
want bool
}{
{
name: "blank case",
origin: "",
method: "",
controllerRequestMethod: "",
want: false,
},
{
name: "normal case",
origin: "http://httpbin.example.com",
method: "Options",
controllerRequestMethod: "PUT",
want: true,
},
{
name: "bad case with diff method",
origin: "http://httpbin.example.com",
method: "GET",
controllerRequestMethod: "PUT",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &CorsConfig{}
assert.Equalf(t, tt.want, c.isPreFlight(tt.origin, tt.method, tt.controllerRequestMethod), "isPreFlight(%v, %v, %v)", tt.origin, tt.method, tt.controllerRequestMethod)
})
}
}
func TestCorsConfig_checkMethods(t *testing.T) {
tests := []struct {
name string
allowMethods []string
requestMethod string
wantMethods string
wantOk bool
}{
{
name: "default *",
allowMethods: []string{"*"},
requestMethod: "GET",
wantMethods: defaultAllAllowMethods,
wantOk: true,
},
{
name: "normal allow case",
allowMethods: []string{"GET", "PUT", "HEAD"},
requestMethod: "get",
wantMethods: "GET,PUT,HEAD",
wantOk: true,
},
{
name: "forbidden case",
allowMethods: []string{"GET", "PUT", "HEAD"},
requestMethod: "POST",
wantMethods: "",
wantOk: false,
},
{
name: "blank method",
allowMethods: []string{"GET", "PUT", "HEAD"},
requestMethod: "",
wantMethods: "",
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &CorsConfig{
allowMethods: tt.allowMethods,
}
allowMethods, allowOk := c.checkMethods(tt.requestMethod)
assert.Equalf(t, tt.wantMethods, allowMethods, "checkMethods(%v)", tt.requestMethod)
assert.Equalf(t, tt.wantOk, allowOk, "checkMethods(%v)", tt.requestMethod)
})
}
}
func TestCorsConfig_checkHeaders(t *testing.T) {
tests := []struct {
name string
allowHeaders []string
requestHeaders string
wantHeaders string
wantOk bool
}{
{
name: "not pre-flight",
allowHeaders: []string{"Content-Type", "Authorization"},
requestHeaders: "",
wantHeaders: "Content-Type,Authorization",
wantOk: true,
},
{
name: "blank allowheaders case 1",
allowHeaders: []string{},
requestHeaders: "",
wantHeaders: "",
wantOk: false,
},
{
name: "blank allowheaders case 2",
requestHeaders: "Authorization",
wantHeaders: "",
wantOk: false,
},
{
name: "allowheaders *",
allowHeaders: []string{"*"},
requestHeaders: "Content-Type,Authorization",
wantHeaders: "Content-Type,Authorization",
wantOk: true,
},
{
name: "allowheader values 1",
allowHeaders: []string{"Content-Type", "Authorization"},
requestHeaders: "Content-Type,Authorization",
wantHeaders: "Content-Type,Authorization",
wantOk: true,
},
{
name: "allowheader values 2",
allowHeaders: []string{"Content-Type", "Authorization"},
requestHeaders: "",
wantHeaders: "Content-Type,Authorization",
wantOk: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &CorsConfig{
allowHeaders: tt.allowHeaders,
}
allowHeaders, allowOk := c.checkHeaders(tt.requestHeaders)
assert.Equalf(t, tt.wantHeaders, allowHeaders, "checkHeaders(%v)", tt.requestHeaders)
assert.Equalf(t, tt.wantOk, allowOk, "checkHeaders(%v)", tt.requestHeaders)
})
}
}
func TestCorsConfig_checkOrigin(t *testing.T) {
tests := []struct {
name string
allowOrigins []string
allowOriginPatterns []OriginPattern
origin string
wantOrigin string
wantOk bool
}{
{
name: "allowOrigins *",
allowOrigins: []string{defaultMatchAll},
allowOriginPatterns: []OriginPattern{},
origin: "http://Httpbin.Example.COM",
wantOrigin: "http://Httpbin.Example.COM",
wantOk: true,
},
{
name: "allowOrigins exact match case 1",
allowOrigins: []string{"http://httpbin.example.com"},
allowOriginPatterns: []OriginPattern{},
origin: "http://HTTPBin.EXample.COM",
wantOrigin: "http://HTTPBin.EXample.COM",
wantOk: true,
},
{
name: "allowOrigins exact match case 2",
allowOrigins: []string{"https://httpbin.example.com"},
allowOriginPatterns: []OriginPattern{},
origin: "http://HTTPBin.EXample.COM",
wantOrigin: "",
wantOk: false,
},
{
name: "OriginPattern pattern match with *",
allowOrigins: []string{},
allowOriginPatterns: []OriginPattern{
newOriginPatternFromString("*"),
},
origin: "http://HTTPBin.EXample.COM",
wantOrigin: "http://HTTPBin.EXample.COM",
wantOk: true,
},
{
name: "OriginPattern pattern match case with any port",
allowOrigins: []string{},
allowOriginPatterns: []OriginPattern{
newOriginPatternFromString("http://*.example.com:[*]"),
},
origin: "http://HTTPBin.EXample.COM",
wantOrigin: "http://HTTPBin.EXample.COM",
wantOk: true,
},
{
name: "OriginPattern pattern match case with any port",
allowOrigins: []string{},
allowOriginPatterns: []OriginPattern{
newOriginPatternFromString("http://*.example.com:[*]"),
},
origin: "http://HTTPBin.EXample.COM:10000",
wantOrigin: "http://HTTPBin.EXample.COM:10000",
wantOk: true,
},
{
name: "OriginPattern pattern match case with specail port 1",
allowOrigins: []string{},
allowOriginPatterns: []OriginPattern{
newOriginPatternFromString("http://*.example.com:[8080,9090]"),
},
origin: "http://HTTPBin.EXample.COM:10000",
wantOrigin: "",
wantOk: false,
},
{
name: "OriginPattern pattern match case with specail port 2",
allowOrigins: []string{},
allowOriginPatterns: []OriginPattern{
newOriginPatternFromString("http://*.example.com:[8080,9090]"),
},
origin: "http://HTTPBin.EXample.COM:9090",
wantOrigin: "http://HTTPBin.EXample.COM:9090",
wantOk: true,
},
{
name: "OriginPattern pattern match case with specail port 3",
allowOrigins: []string{},
allowOriginPatterns: []OriginPattern{
newOriginPatternFromString("http://*.example.com:[8080,9090]"),
},
origin: "http://HTTPBin.EXample.org:9090",
wantOrigin: "",
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &CorsConfig{
allowOrigins: tt.allowOrigins,
allowOriginPatterns: tt.allowOriginPatterns,
}
allowOrigin, allowOk := c.checkOrigin(tt.origin)
assert.Equalf(t, tt.wantOrigin, allowOrigin, "checkOrigin(%v)", tt.origin)
assert.Equalf(t, tt.wantOk, allowOk, "checkOrigin(%v)", tt.origin)
})
}
}

View File

@@ -0,0 +1,67 @@
apiVersion: networking.higress.io/v1
kind: McpBridge
metadata:
name: mcp-cors-httpbin
namespace: higress-system
spec:
registries:
- domain: httpbin.org
name: httpbin
port: 80
type: dns
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
higress.io/destination: httpbin.dns
higress.io/upstream-vhost: "httpbin.org"
higress.io/backend-protocol: HTTP
name: ingress-cors-httpbin
namespace: higress-system
spec:
ingressClassName: higress
rules:
- host: httpbin.example.com
http:
paths:
- backend:
resource:
apiGroup: networking.higress.io
kind: McpBridge
name: mcp-cors-httpbin
path: /
pathType: Prefix
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: wasm-cors-httpbin
namespace: higress-system
spec:
defaultConfigDisable: true
matchRules:
- config:
allow_origins:
- http://httpbin.example.net
allow_origin_patterns:
- http://*.example.com:[*]
- http://*.example.org:[9090,8080]
allow_methods:
- GET
- POST
- PATCH
allow_headers:
- Content-Type
- Token
- Authorization
expose_headers:
- X-Custom-Header
- X-Env-UTM
allow_credentials: true
max_age: 3600
configDisable: false
ingress:
- ingress-cors-httpbin
url: oci://docker.io/2456868764/cors:1.0.0
imagePullPolicy: Always

View File

@@ -0,0 +1,78 @@
static_resources:
listeners:
- name: main
address:
socket_address:
address: 0.0.0.0
port_value: 18000
filter_chains:
- filters:
- name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: auto
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains:
- "httpbin.example.com"
routes:
- match:
prefix: "/"
route:
cluster: httpbin
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
configuration:
"@type": type.googleapis.com/google.protobuf.StringValue
value: |-
{
"allow_origins": ["http://httpbin.example.net"],
"allow_origin_patterns": ["http://*.example.com:[*]", "http://*.example.org:[9090,8080]"],
"allow_methods": ["GET","PUT","POST", "PATCH", "HEAD", "OPTIONS"],
"allow_credentials": true,
"allow_headers":["Content-Type", "Token","Authorization"],
"expose_headers":["X-Custom-Header"],
"max_age": 3600
}
vm_config:
runtime: "envoy.wasm.runtime.v8"
code:
local:
filename: "./main.wasm"
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
connect_timeout: 0.5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
dns_refresh_rate: 5s
dns_lookup_family: V4_ONLY
load_assignment:
cluster_name: httpbin
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin.org
port_value: 80
admin:
access_log_path: "/dev/null"
address:
socket_address:
address: 0.0.0.0
port_value: 8001

View File

@@ -0,0 +1,21 @@
module cors
go 1.19
replace github.com/alibaba/higress/plugins/wasm-go => ../..
require (
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230519024024-625c06e58f91
github.com/stretchr/testify v1.8.3
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0
github.com/tidwall/gjson v1.14.4
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,20 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0 h1:kS7BvMKN+FiptV4pfwiNX8e3q14evxAWkhYbxt8EI1M=
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0/go.mod h1:qkW5MBz2jch2u8bS59wws65WC+Gtx3x0aPUX5JL7CXI=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed 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.
package main
import (
"cors/config"
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {
wrapper.SetCtx(
"cors",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
)
}
func parseConfig(json gjson.Result, corsConfig *config.CorsConfig, log wrapper.Log) error {
log.Debug("parseConfig()")
allowOrigins := json.Get("allow_origins").Array()
for _, origin := range allowOrigins {
if err := corsConfig.AddAllowOrigin(origin.String()); err != nil {
log.Warnf("failed to AddAllowOrigin:%s, error:%v", origin, err)
}
}
allowOriginPatterns := json.Get("allow_origin_patterns").Array()
for _, pattern := range allowOriginPatterns {
corsConfig.AddAllowOriginPattern(pattern.String())
}
allowMethods := json.Get("allow_methods").Array()
for _, method := range allowMethods {
corsConfig.AddAllowMethod(method.String())
}
allowHeaders := json.Get("allow_headers").Array()
for _, header := range allowHeaders {
corsConfig.AddAllowHeader(header.String())
}
exposeHeaders := json.Get("expose_headers").Array()
for _, header := range exposeHeaders {
corsConfig.AddExposeHeader(header.String())
}
allowCredentials := json.Get("allow_credentials").Bool()
if err := corsConfig.SetAllowCredentials(allowCredentials); err != nil {
log.Warnf("failed to set AllowCredentials error: %v", err)
}
maxAge := json.Get("max_age").Int()
corsConfig.SetMaxAge(int(maxAge))
// Fill default values
corsConfig.FillDefaultValues()
log.Debugf("corsConfig:%+v", corsConfig)
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, corsConfig config.CorsConfig, log wrapper.Log) types.Action {
log.Debug("onHttpRequestHeaders()")
requestUrl, _ := proxywasm.GetHttpRequestHeader(":path")
method, _ := proxywasm.GetHttpRequestHeader(":method")
host := ctx.Host()
scheme := ctx.Scheme()
log.Debugf("scheme:%s, host:%s, method:%s, request:%s", scheme, host, method, requestUrl)
// Get headers
headers, _ := proxywasm.GetHttpRequestHeaders()
// Process request
httpCorsContext, err := corsConfig.Process(scheme, host, method, headers)
if err != nil {
log.Warnf("failed to process %s : %v", requestUrl, err)
return types.ActionContinue
}
log.Debugf("Process httpCorsContext:%+v", httpCorsContext)
// Set HttpContext
ctx.SetContext(config.HttpContextKey, httpCorsContext)
// Response forbidden when it is not valid cors request
if !httpCorsContext.IsValid {
headers := make([][2]string, 0)
headers = append(headers, [2]string{config.HeaderPluginTrace, "trace"})
proxywasm.SendHttpResponse(403, headers, []byte("Invalid CORS request"), -1)
return types.ActionPause
}
// Response directly when it is cors preflight request
if httpCorsContext.IsPreFlight {
headers := make([][2]string, 0)
headers = append(headers, [2]string{config.HeaderPluginTrace, "trace"})
proxywasm.SendHttpResponse(200, headers, nil, -1)
return types.ActionPause
}
return types.ActionContinue
}
func onHttpRequestBody(ctx wrapper.HttpContext, corsConfig config.CorsConfig, body []byte, log wrapper.Log) types.Action {
log.Debug("onHttpRequestBody()")
return types.ActionContinue
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, corsConfig config.CorsConfig, log wrapper.Log) types.Action {
log.Debug("onHttpResponseHeaders()")
// Remove trace header if existed
proxywasm.RemoveHttpResponseHeader(config.HeaderPluginTrace)
// Remove upstream cors response headers if existed
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowOrigin)
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowMethods)
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlExposeHeaders)
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowCredentials)
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlMaxAge)
// Add debug header
proxywasm.AddHttpResponseHeader(config.HeaderPluginDebug, corsConfig.GetVersion())
// Restore httpCorsContext from HttpContext
httpCorsContext, ok := ctx.GetContext(config.HttpContextKey).(config.HttpCorsContext)
if !ok {
log.Debug("restore httpCorsContext from HttpContext error")
return types.ActionContinue
}
log.Debugf("Restore httpCorsContext:%+v", httpCorsContext)
// Skip which it is not valid or not cors request
if !httpCorsContext.IsValid || !httpCorsContext.IsCorsRequest {
return types.ActionContinue
}
// Add Cors headers when it is cors and valid request
if len(httpCorsContext.AllowOrigin) > 0 {
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowOrigin, httpCorsContext.AllowOrigin)
}
if len(httpCorsContext.AllowMethods) > 0 {
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowMethods, httpCorsContext.AllowMethods)
}
if len(httpCorsContext.AllowHeaders) > 0 {
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowHeaders, httpCorsContext.AllowHeaders)
}
if len(httpCorsContext.ExposeHeaders) > 0 {
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlExposeHeaders, httpCorsContext.ExposeHeaders)
}
if httpCorsContext.AllowCredentials {
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowCredentials, "true")
}
if httpCorsContext.MaxAge > 0 {
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlMaxAge, fmt.Sprintf("%d", httpCorsContext.MaxAge))
}
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, corsConfig config.CorsConfig, body []byte, log wrapper.Log) types.Action {
log.Debug("onHttpResponseBody()")
return types.ActionContinue
}