diff --git a/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum b/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum index 86bcb981eb..10e66f8ec0 100644 --- a/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum +++ b/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum @@ -24,6 +24,7 @@ github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= github.com/wasilibs/go-re2 v1.5.3 h1:wiuTcgDZdLhu8NG8oqF5sF5Q3yIU14lPAvXqeYzDK3g= github.com/wasilibs/go-re2 v1.5.3/go.mod h1:PzpVPsBdFC7vM8QJbbEnOeTmwA0DGE783d/Gex8eCV8= +github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ= github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837 h1:DjHnADS2r2zynZ3WkCFAQ+PNYngMSNceRROi0pO6c3M= github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837/go.mod h1:9vp0bxqozzQwcjBwenEXfKVq8+mYbwHkQ1NF9Ap0DMw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/plugins/wasm-go/extensions/ext-auth/README.md b/plugins/wasm-go/extensions/ext-auth/README.md new file mode 100644 index 0000000000..4dc306cd99 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/README.md @@ -0,0 +1,162 @@ +# 功能说明 + +`ext-auth` 插件实现了向外部授权服务发送鉴权请求,以检查客户端请求是否得到授权。该插件实现时参考了Envoy原生的[ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter),实现了原生filter中对接HTTP服务的部分能力 + + + +# 配置字段 + +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| ------------------------------- | -------- | ---- | ------ |------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `http_service` | object | 是 | - | 外部授权服务配置 | +| `failure_mode_allow` | bool | 否 | false | 当设置为 true 时,即使与授权服务的通信失败,或者授权服务返回了 HTTP 5xx 错误,仍会接受客户端请求 | +| `failure_mode_allow_header_add` | bool | 否 | false | 当 `failure_mode_allow` 和 `failure_mode_allow_header_add` 都设置为 true 时,若与授权服务的通信失败,或授权服务返回了 HTTP 5xx 错误,那么请求头中将会添加 `x-envoy-auth-failure-mode-allowed: true` | +| `status_on_error` | int | 否 | 403 | 当授权服务无法访问或状态码为 5xx 时,设置返回给客户端的 HTTP 状态码。默认状态码是 `403` | + +`http_service`中每一项的配置字段说明 + +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| ------------------------ | -------- | ---- | ------ | ------------------------------------- | +| `endpoint` | object | 是 | - | 发送鉴权请求的 HTTP 服务信息 | +| `timeout` | int | 否 | 200 | `ext-auth` 服务连接超时时间,单位毫秒 | +| `authorization_request` | object | 否 | - | 发送鉴权请求配置 | +| `authorization_response` | object | 否 | - | 处理鉴权响应配置 | + +`endpoint`中每一项的配置字段说明 + +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| ---------------- | -------- | ---- | ------ | --------------------------------------------------- | +| `service_source` | string | 是 | - | 类型为固定 ip 或者 dns,输入授权服务的注册来源 | +| `service_name` | string | 是 | - | 输入授权服务的注册名称 | +| `service_port` | string | 是 | - | 输入授权服务的服务端口 | +| `service_domain` | string | 否 | - | 当类型为dns时必须填写,输入 `ext-auth` 服务的domain | +| `request_method` | string | 否 | GET | 客户端向授权服务发送请求的HTTP Method | +| `path` | string | 是 | - | 输入授权服务的请求路径 | + +`authorization_request`中每一项的配置字段说明 + +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| ------------------- | ---------------------- | ---- | ------ | ------------------------------------------------------------ | +| `allowed_headers` | array of StringMatcher | 否 | - | 当设置后,具有相应匹配项的客户端请求头将添加到授权服务请求中的请求头中。除了用户自定义的头部匹配规则外,授权服务请求中会自动包含`Host`, `Method`, `Path`, `Content-Length` 和 `Authorization`这几个关键的HTTP头 | +| `headers_to_add` | `map[string]string` | 否 | - | 设置将包含在授权服务请求中的请求头列表。请注意,同名的客户端请求头将被覆盖 | +| `with_request_body` | bool | 否 | false | 缓冲客户端请求体,并将其发送至鉴权请求中(HTTP Method为GET、OPTIONS、HEAD请求时不生效) | + +`authorization_response`中每一项的配置字段说明 + +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| -------------------------- | ---------------------- | ---- | ------ |---------------------------------------------------------------------------------| +| `allowed_upstream_headers` | array of StringMatcher | 否 | - | 当设置后,具有相应匹配项的鉴权请求的响应头将添加到原始的客户端请求头中。请注意,同名的请求头将被覆盖 | +| `allowed_client_headers` | array of StringMatcher | 否 | - | 如果不设置,在请求被拒绝时,所有的鉴权请求的响应头将添加到客户端的响应头中。当设置后,在请求被拒绝时,具有相应匹配项的鉴权请求的响应头将添加到客户端的响应头中 | + +`StringMatcher`类型每一项的配置字段说明,在使用`array of StringMatcher`时会按照数组中定义的StringMatcher顺序依次进行配置 + +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| ---------- | -------- | ------------------------------------------------------------ | ------ | -------- | +| `exact` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 精确匹配 | +| `prefix` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 前缀匹配 | +| `suffix` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 后缀匹配 | +| `contains` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 是否包含 | +| `regex` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 正则匹配 | + + + +# 配置示例 + +下面假设 `ext-auth` 服务在Kubernetes中serviceName为 `ext-auth`,端口 `8090`,路径为 `/auth`,命名空间为 `backend` + +## 示例1 + +`ext-auth` 插件的配置: + +```yaml +http_service: + endpoint: + service_name: ext-auth + namespace: backend + service_port: 8090 + service_source: k8s + path: /auth + request_method: POST + timeout: 500 +``` + +使用如下请求网关,当开启 `ext-auth` 插件后: + +```shell +curl -i http://localhost:8082/users -X GET -H "foo: bar" -H "Authorization: xxx" +``` + +**请求 `ext-auth` 服务成功:** + +`ext-auth` 服务将接收到如下的鉴权请求: + +``` +POST /auth HTTP/1.1 +Host: ext-auth +Authorization: xxx +Content-Length: 0 +``` + +**请求 `ext-auth` 服务失败:** + +当调用 `ext-auth` 服务响应为 5xx 时,客户端将接收到HTTP响应码403和 `ext-auth` 服务返回的全量响应头 + +假如 `ext-auth` 服务返回了 `x-auth-version: 1.0` 和 `x-auth-failed: true` 的响应头,会传递给客户端 + +``` +HTTP/1.1 403 Forbidden +x-auth-version: 1.0 +x-auth-failed: true +date: Tue, 16 Jul 2024 00:19:41 GMT +server: istio-envoy +content-length: 0 +``` + +当 `ext-auth` 无法访问或状态码为 5xx 时,将以 `status_on_error` 配置的状态码拒绝客户端请求 + +当 `ext-auth` 服务返回其他 HTTP 状态码时,将以返回的状态码拒绝客户端请求。如果配置了 `allowed_client_headers`,具有相应匹配项的响应头将添加到客户端的响应中 + + +## 示例2 + +`ext-auth` 插件的配置: + +```yaml +http_service: + authorization_request: + allowed_headers: + - exact: x-auth-version + headers_to_add: + x-envoy-header: true + authorization_response: + allowed_upstream_headers: + - exact: x-user-id + - exact: x-auth-version + endpoint: + service_name: ext-auth + namespace: backend + service_port: 8090 + service_source: k8s + path: /auth + request_method: POST + timeout: 500 +``` + +使用如下请求网关,当开启 `ext-auth` 插件后: + +```shell +curl -i http://localhost:8082/users -X GET -H "foo: bar" -H "Authorization: xxx" -H "X-Auth-Version: 1.0" +``` + +`ext-auth` 服务将接收到如下的鉴权请求: + +``` +POST /auth HTTP/1.1 +Host: ext-auth +Authorization: xxx +X-Auth-Version: 1.0 +x-envoy-header: true +Content-Length: 0 +``` + +`ext-auth` 服务返回响应头中如果包含 `x-user-id` 和 `x-auth-version`,网关调用upstream时的请求中会带上这两个请求头 diff --git a/plugins/wasm-go/extensions/ext-auth/config.go b/plugins/wasm-go/extensions/ext-auth/config.go new file mode 100644 index 0000000000..13aff15de9 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/config.go @@ -0,0 +1,229 @@ +package main + +import ( + "errors" + "ext-auth/expr" + "fmt" + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/tidwall/gjson" + "net/http" + "strings" +) + +const ( + DefaultStatusOnError uint32 = http.StatusForbidden + + DefaultHttpServiceTimeout uint32 = 200 +) + +type ExtAuthConfig struct { + httpService HttpService + failureModeAllow bool + failureModeAllowHeaderAdd bool + statusOnError uint32 +} + +type HttpService struct { + client wrapper.HttpClient + requestMethod string + path string + timeout uint32 + authorizationRequest AuthorizationRequest + authorizationResponse AuthorizationResponse +} + +type AuthorizationRequest struct { + // allowedHeaders In addition to the user’s supplied matchers, + // Host, Method, Path, Content-Length, and Authorization are automatically included to the list. + allowedHeaders expr.Matcher + headersToAdd map[string]string + withRequestBody bool +} + +type AuthorizationResponse struct { + allowedUpstreamHeaders expr.Matcher + allowedClientHeaders expr.Matcher +} + +func parseConfig(json gjson.Result, config *ExtAuthConfig, log wrapper.Log) error { + httpServiceConfig := json.Get("http_service") + if !httpServiceConfig.Exists() { + return errors.New("missing http_service in config") + } + err := parseHttpServiceConfig(httpServiceConfig, config) + if err != nil { + return err + } + + failureModeAllow := json.Get("failure_mode_allow") + if failureModeAllow.Exists() { + config.failureModeAllow = failureModeAllow.Bool() + } + + failureModeAllowHeaderAdd := json.Get("failure_mode_allow_header_add") + if failureModeAllowHeaderAdd.Exists() { + config.failureModeAllowHeaderAdd = failureModeAllowHeaderAdd.Bool() + } + + statusOnError := json.Get("status_on_error") + if statusOnError.Exists() { + config.statusOnError = uint32(statusOnError.Uint()) + } else { + config.statusOnError = DefaultStatusOnError + } + + return nil +} + +func parseHttpServiceConfig(json gjson.Result, config *ExtAuthConfig) error { + var httpService HttpService + + if err := parseEndpointConfig(json, &httpService); err != nil { + return err + } + + timeout := uint32(json.Get("timeout").Uint()) + if timeout == 0 { + timeout = DefaultHttpServiceTimeout + } + httpService.timeout = timeout + + if err := parseAuthorizationRequestConfig(json, &httpService); err != nil { + return err + } + + if err := parseAuthorizationResponseConfig(json, &httpService); err != nil { + return err + } + + config.httpService = httpService + + return nil +} + +func parseEndpointConfig(json gjson.Result, httpService *HttpService) error { + endpointConfig := json.Get("endpoint") + if !endpointConfig.Exists() { + return errors.New("missing endpoint in config") + } + + serviceSource := endpointConfig.Get("service_source").String() + serviceName := endpointConfig.Get("service_name").String() + servicePort := endpointConfig.Get("service_port").Int() + if serviceName == "" || servicePort == 0 { + return errors.New("invalid service config") + } + switch serviceSource { + case "k8s": + namespace := json.Get("namespace").String() + httpService.client = wrapper.NewClusterClient(wrapper.K8sCluster{ + ServiceName: serviceName, + Namespace: namespace, + Port: servicePort, + }) + return nil + case "nacos": + namespace := json.Get("namespace").String() + httpService.client = wrapper.NewClusterClient(wrapper.NacosCluster{ + ServiceName: serviceName, + NamespaceID: namespace, + Port: servicePort, + }) + return nil + case "ip": + httpService.client = wrapper.NewClusterClient(wrapper.StaticIpCluster{ + ServiceName: serviceName, + Port: servicePort, + }) + case "dns": + domain := endpointConfig.Get("domain").String() + httpService.client = wrapper.NewClusterClient(wrapper.DnsCluster{ + ServiceName: serviceName, + Port: servicePort, + Domain: domain, + }) + default: + return errors.New("unknown service source: " + serviceSource) + } + + requestMethodConfig := endpointConfig.Get("request_method") + if !requestMethodConfig.Exists() { + httpService.requestMethod = http.MethodGet + } else { + httpService.requestMethod = strings.ToUpper(requestMethodConfig.String()) + } + + pathConfig := endpointConfig.Get("path") + if !pathConfig.Exists() { + return errors.New("missing path in config") + } + httpService.path = pathConfig.String() + + return nil +} + +func parseAuthorizationRequestConfig(json gjson.Result, httpService *HttpService) error { + authorizationRequestConfig := json.Get("authorization_request") + if authorizationRequestConfig.Exists() { + var authorizationRequest AuthorizationRequest + + headersToAdd := map[string]string{} + headersToAddConfig := authorizationRequestConfig.Get("headers_to_add") + if headersToAddConfig.Exists() { + for key, value := range headersToAddConfig.Map() { + headersToAdd[key] = value.Str + } + } + authorizationRequest.headersToAdd = headersToAdd + + withRequestBody := authorizationRequestConfig.Get("with_request_body") + if withRequestBody.Exists() { + // withRequestBody is true and the request method is GET, OPTIONS or HEAD + if withRequestBody.Bool() && + (httpService.requestMethod == http.MethodGet || httpService.requestMethod == http.MethodOptions || httpService.requestMethod == http.MethodHead) { + return errors.New(fmt.Sprintf("requestMethod %s does not support with_request_body set to true", httpService.requestMethod)) + } + authorizationRequest.withRequestBody = withRequestBody.Bool() + } + + allowedHeaders := authorizationRequestConfig.Get("allowed_headers") + if allowedHeaders.Exists() { + result, err := expr.BuildRepeatedStringMatcherIgnoreCase(allowedHeaders.Array()) + if err != nil { + return err + } + authorizationRequest.allowedHeaders = result + } + + httpService.authorizationRequest = authorizationRequest + } + return nil +} + +func parseAuthorizationResponseConfig(json gjson.Result, httpService *HttpService) error { + authorizationResponseConfig := json.Get("authorization_response") + if authorizationResponseConfig.Exists() { + var authorizationResponse AuthorizationResponse + + allowedUpstreamHeaders := authorizationResponseConfig.Get("allowed_upstream_headers") + if allowedUpstreamHeaders.Exists() { + result, err := expr.BuildRepeatedStringMatcherIgnoreCase(allowedUpstreamHeaders.Array()) + if err != nil { + return err + } + authorizationResponse.allowedUpstreamHeaders = result + } + + allowedClientHeaders := authorizationResponseConfig.Get("allowed_client_headers") + if allowedClientHeaders.Exists() { + result, err := expr.BuildRepeatedStringMatcherIgnoreCase(allowedClientHeaders.Array()) + if err != nil { + return err + } + authorizationResponse.allowedClientHeaders = result + } + + httpService.authorizationResponse = authorizationResponse + } + return nil +} diff --git a/plugins/wasm-go/extensions/ext-auth/expr/matcher.go b/plugins/wasm-go/extensions/ext-auth/expr/matcher.go new file mode 100644 index 0000000000..d00fdf4624 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/expr/matcher.go @@ -0,0 +1,201 @@ +package expr + +import ( + "errors" + "github.com/tidwall/gjson" + "regexp" + "strings" +) + +const ( + matchPatternExact string = "exact" + matchPatternPrefix string = "prefix" + matchPatternSuffix string = "suffix" + matchPatternContains string = "contains" + matchPatternRegex string = "regex" + + matchIgnoreCase string = "ignore_case" +) + +type Matcher interface { + Match(s string) bool +} + +type stringExactMatcher struct { + target string + ignoreCase bool +} + +func (m *stringExactMatcher) Match(s string) bool { + if m.ignoreCase { + return strings.ToLower(s) == m.target + } + return s == m.target +} + +type stringPrefixMatcher struct { + target string + ignoreCase bool +} + +func (m *stringPrefixMatcher) Match(s string) bool { + if m.ignoreCase { + return strings.HasPrefix(strings.ToLower(s), m.target) + } + return strings.HasPrefix(s, m.target) +} + +type stringSuffixMatcher struct { + target string + ignoreCase bool +} + +func (m *stringSuffixMatcher) Match(s string) bool { + if m.ignoreCase { + return strings.HasSuffix(strings.ToLower(s), m.target) + } + return strings.HasSuffix(s, m.target) +} + +type stringContainsMatcher struct { + target string + ignoreCase bool +} + +func (m *stringContainsMatcher) Match(s string) bool { + if m.ignoreCase { + return strings.Contains(strings.ToLower(s), m.target) + } + return strings.Contains(s, m.target) +} + +type stringRegexMatcher struct { + regex *regexp.Regexp +} + +func (m *stringRegexMatcher) Match(s string) bool { + return m.regex.MatchString(s) +} + +type repeatedStringMatcher struct { + matchers []Matcher +} + +func (rsm *repeatedStringMatcher) Match(s string) bool { + for _, m := range rsm.matchers { + if m.Match(s) { + return true + } + } + return false +} + +func buildRepeatedStringMatcher(matchers []gjson.Result, allIgnoreCase bool) (Matcher, error) { + builtMatchers := make([]Matcher, len(matchers)) + + createMatcher := func(json gjson.Result, targetKey string, ignoreCase bool, matcherType MatcherConstructor) (Matcher, error) { + result := json.Get(targetKey) + if result.Exists() && result.String() != "" { + target := result.String() + return matcherType(target, ignoreCase) + } + return nil, nil + } + + for i, item := range matchers { + var matcher Matcher + var err error + + // If allIgnoreCase is true, it takes precedence over any user configuration, + // forcing case-insensitive matching regardless of individual item settings. + ignoreCase := allIgnoreCase + if !allIgnoreCase { + ignoreCaseResult := item.Get(matchIgnoreCase) + if ignoreCaseResult.Exists() && ignoreCaseResult.Bool() { + ignoreCase = true + } + } + + for _, matcherType := range []struct { + key string + creator MatcherConstructor + }{ + {matchPatternExact, newStringExactMatcher}, + {matchPatternPrefix, newStringPrefixMatcher}, + {matchPatternSuffix, newStringSuffixMatcher}, + {matchPatternContains, newStringContainsMatcher}, + {matchPatternRegex, newStringRegexMatcher}, + } { + if matcher, err = createMatcher(item, matcherType.key, ignoreCase, matcherType.creator); err != nil { + return nil, err + } + if matcher != nil { + break + } + } + + if matcher == nil { + return nil, errors.New("unknown string matcher type") + } + + builtMatchers[i] = matcher + + } + + return &repeatedStringMatcher{ + matchers: builtMatchers, + }, nil +} + +type MatcherConstructor func(string, bool) (Matcher, error) + +func newStringExactMatcher(target string, ignoreCase bool) (Matcher, error) { + if ignoreCase { + target = strings.ToLower(target) + } + return &stringExactMatcher{target: target, ignoreCase: ignoreCase}, nil +} + +func newStringPrefixMatcher(target string, ignoreCase bool) (Matcher, error) { + if ignoreCase { + target = strings.ToLower(target) + } + return &stringPrefixMatcher{target: target, ignoreCase: ignoreCase}, nil +} + +func newStringSuffixMatcher(target string, ignoreCase bool) (Matcher, error) { + if ignoreCase { + target = strings.ToLower(target) + } + return &stringSuffixMatcher{target: target, ignoreCase: ignoreCase}, nil +} + +func newStringContainsMatcher(target string, ignoreCase bool) (Matcher, error) { + if ignoreCase { + target = strings.ToLower(target) + } + return &stringContainsMatcher{target: target, ignoreCase: ignoreCase}, nil +} + +func newStringRegexMatcher(target string, ignoreCase bool) (Matcher, error) { + if ignoreCase && !strings.HasPrefix(target, "(?i)") { + target = "(?i)" + target + } + re, err := regexp.Compile(target) + if err != nil { + return nil, err + } + return &stringRegexMatcher{regex: re}, nil +} + +func BuildRepeatedStringMatcherIgnoreCase(matchers []gjson.Result) (Matcher, error) { + return buildRepeatedStringMatcher(matchers, true) +} + +func BuildRepeatedStringMatcher(matchers []gjson.Result) (Matcher, error) { + return buildRepeatedStringMatcher(matchers, false) +} + +func BuildStringMatcher(matcher gjson.Result) (Matcher, error) { + return BuildRepeatedStringMatcher([]gjson.Result{matcher}) +} diff --git a/plugins/wasm-go/extensions/ext-auth/expr/matcher_test.go b/plugins/wasm-go/extensions/ext-auth/expr/matcher_test.go new file mode 100644 index 0000000000..fcaa06b68b --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/expr/matcher_test.go @@ -0,0 +1,121 @@ +package expr + +import ( + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" + "testing" +) + +func TestStringMatcher(t *testing.T) { + tests := []struct { + name string + cfg string + matched []string + mismatched []string + }{ + { + name: "exact", + cfg: `{"exact": "foo"}`, + matched: []string{"foo"}, + mismatched: []string{"fo", "fooo"}, + }, + { + name: "exact, ignore_case", + cfg: `{"exact": "foo", "ignore_case": true}`, + matched: []string{"Foo", "foo"}, + }, + { + name: "prefix", + cfg: `{"prefix": "/p"}`, + matched: []string{"/p", "/pa"}, + mismatched: []string{"/P"}, + }, + { + name: "prefix, ignore_case", + cfg: `{"prefix": "/p", "ignore_case": true}`, + matched: []string{"/P", "/p", "/pa", "/Pa"}, + mismatched: []string{"/"}, + }, + { + name: "suffix", + cfg: `{"suffix": "foo"}`, + matched: []string{"foo", "0foo"}, + mismatched: []string{"fo", "fooo", "aFoo"}, + }, + { + name: "suffix, ignore_case", + cfg: `{"suffix": "foo", "ignore_case": true}`, + matched: []string{"aFoo", "foo"}, + mismatched: []string{"fo", "fooo"}, + }, + { + name: "contains", + cfg: `{"contains": "foo"}`, + matched: []string{"foo", "0foo", "fooo"}, + mismatched: []string{"fo", "aFoo"}, + }, + { + name: "contains, ignore_case", + cfg: `{"contains": "foo", "ignore_case": true}`, + matched: []string{"aFoo", "foo", "FoO"}, + mismatched: []string{"fo"}, + }, + { + name: "regex", + cfg: `{"regex": "fo{2}"}`, + matched: []string{"foo", "0foo", "fooo"}, + mismatched: []string{"aFoo", "fo"}, + }, + { + name: "regex, ignore_case", + cfg: `{"regex": "fo{2}", "ignore_case": true}`, + matched: []string{"foo", "0foo", "fooo", "aFoo"}, + mismatched: []string{"fo"}, + }, + { + name: "regex, ignore_case & case insensitive specified in regex", + cfg: `{"regex": "(?i)fo{2}", "ignore_case": true}`, + matched: []string{"foo", "0foo", "fooo", "aFoo"}, + mismatched: []string{"fo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + built, _ := BuildStringMatcher(gjson.Parse(tt.cfg)) + for _, s := range tt.matched { + assert.True(t, built.Match(s)) + } + for _, s := range tt.mismatched { + assert.False(t, built.Match(s)) + } + }) + } +} + +func TestBuildRepeatedStringMatcherIgnoreCase(t *testing.T) { + cfgs := []string{ + `{"exact":"foo"}`, + `{"prefix":"pre"}`, + `{"regex":"^Cache"}`, + } + matched := []string{"Foo", "foO", "foo", "PreA", "cache-control", "Cache-Control"} + mismatched := []string{"afoo", "fo"} + ms := []gjson.Result{} + for _, cfg := range cfgs { + ms = append(ms, gjson.Parse(cfg)) + } + built, _ := BuildRepeatedStringMatcherIgnoreCase(ms) + for _, s := range matched { + assert.True(t, built.Match(s)) + } + for _, s := range mismatched { + assert.False(t, built.Match(s)) + } +} + +func TestPassOutRegexCompileErr(t *testing.T) { + cfg := `{"regex":"(?!)aa"}` + _, err := BuildRepeatedStringMatcher([]gjson.Result{gjson.Parse(cfg)}) + assert.NotNil(t, err) +} diff --git a/plugins/wasm-go/extensions/ext-auth/go.mod b/plugins/wasm-go/extensions/ext-auth/go.mod new file mode 100644 index 0000000000..7fb2759714 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/go.mod @@ -0,0 +1,24 @@ +module ext-auth + +go 1.19 + +replace github.com/alibaba/higress/plugins/wasm-go => ../.. + +require ( + github.com/alibaba/higress/plugins/wasm-go v0.0.0 + github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f + github.com/stretchr/testify v1.8.4 + github.com/tidwall/gjson v1.14.3 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect + github.com/magefile/mage v1.14.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 + github.com/tidwall/resp v0.1.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/wasm-go/extensions/ext-auth/go.sum b/plugins/wasm-go/extensions/ext-auth/go.sum new file mode 100644 index 0000000000..e726b100a5 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/go.sum @@ -0,0 +1,26 @@ +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/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA= +github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= +github.com/tidwall/gjson v1.14.3/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= +github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= +github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +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= diff --git a/plugins/wasm-go/extensions/ext-auth/main.go b/plugins/wasm-go/extensions/ext-auth/main.go new file mode 100644 index 0000000000..8ab18fd870 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/main.go @@ -0,0 +1,147 @@ +// Copyright (c) 2024 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 ( + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "net/http" + "strconv" +) + +func main() { + wrapper.SetCtx( + "ext-auth", + wrapper.ParseConfigBy(parseConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + ) +} + +const ( + HeaderContentLength string = "content-length" + HeaderAuthorization string = "authorization" + HeaderFailureModeAllow string = "x-envoy-auth-failure-mode-allowed" +) + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config ExtAuthConfig, log wrapper.Log) types.Action { + contentLengthStr, _ := proxywasm.GetHttpRequestHeader(HeaderContentLength) + hasRequestBody := false + if contentLengthStr != "" { + contentLength, err := strconv.Atoi(contentLengthStr) + hasRequestBody = err == nil && contentLength > 0 + } + // If withRequestBody is true AND the HTTP request contains a request body, + // it will be handled in the onHttpRequestBody phase. + if config.httpService.authorizationRequest.withRequestBody && hasRequestBody { + // Disable the route re-calculation since the plugin may modify some headers related to the chosen route. + ctx.DisableReroute() + // The request has a body and requires delaying the header transmission until a cache miss occurs, + // at which point the header should be sent. + return types.HeaderStopIteration + } + ctx.DontReadRequestBody() + return checkExtAuth(ctx, config, nil, log) +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config ExtAuthConfig, body []byte, log wrapper.Log) types.Action { + if config.httpService.authorizationRequest.withRequestBody { + return checkExtAuth(ctx, config, body, log) + } + return types.ActionContinue +} + +func checkExtAuth(ctx wrapper.HttpContext, config ExtAuthConfig, body []byte, log wrapper.Log) types.Action { + // build extAuth request headers + extAuthReqHeaders := http.Header{} + + httpServiceConfig := config.httpService + requestConfig := httpServiceConfig.authorizationRequest + reqHeaders, _ := proxywasm.GetHttpRequestHeaders() + if requestConfig.allowedHeaders != nil { + for _, header := range reqHeaders { + headK := header[0] + if requestConfig.allowedHeaders.Match(headK) { + extAuthReqHeaders.Set(headK, header[1]) + } + } + } + + for key, value := range requestConfig.headersToAdd { + extAuthReqHeaders.Set(key, value) + } + + // add Authorization header + authorization := extractFromHeader(reqHeaders, HeaderAuthorization) + if authorization != "" { + extAuthReqHeaders.Set(HeaderAuthorization, authorization) + } + + // call ext auth server + err := httpServiceConfig.client.Post(httpServiceConfig.path, reconvertHeaders(extAuthReqHeaders), body, + func(statusCode int, responseHeaders http.Header, responseBody []byte) { + defer proxywasm.ResumeHttpRequest() + if statusCode != http.StatusOK { + log.Errorf("failed to call ext auth server, status: %d", statusCode) + callExtAuthServerErrorHandler(config, statusCode, responseHeaders) + return + } + + if httpServiceConfig.authorizationResponse.allowedUpstreamHeaders != nil { + for headK, headV := range responseHeaders { + if httpServiceConfig.authorizationResponse.allowedUpstreamHeaders.Match(headK) { + _ = proxywasm.ReplaceHttpRequestHeader(headK, headV[0]) + } + } + } + + }, httpServiceConfig.timeout) + + if err != nil { + log.Errorf("failed to call ext auth server: %v", err) + // Since the handling logic for call errors and HTTP status code 500 is the same, we directly use 500 here. + callExtAuthServerErrorHandler(config, http.StatusInternalServerError, nil) + return types.ActionContinue + } + return types.ActionPause +} + +func callExtAuthServerErrorHandler(config ExtAuthConfig, statusCode int, extAuthRespHeaders http.Header) { + if statusCode >= http.StatusInternalServerError && config.failureModeAllow { + if config.failureModeAllowHeaderAdd { + _ = proxywasm.ReplaceHttpRequestHeader(HeaderFailureModeAllow, "true") + } + return + } + + var respHeaders = extAuthRespHeaders + if config.httpService.authorizationResponse.allowedClientHeaders != nil { + respHeaders = http.Header{} + for headK, headV := range extAuthRespHeaders { + if config.httpService.authorizationResponse.allowedClientHeaders.Match(headK) { + respHeaders.Set(headK, headV[0]) + } + } + } + + // Rejects client requests with statusOnError on extAuth unavailability or 5xx. + // Otherwise, uses the extAuth's returned status code to reject requests. + statusToUse := statusCode + if statusCode >= http.StatusInternalServerError { + statusToUse = int(config.statusOnError) + } + _ = sendResponse(uint32(statusToUse), "ext-auth.unauthorized", respHeaders) +} diff --git a/plugins/wasm-go/extensions/ext-auth/utils.go b/plugins/wasm-go/extensions/ext-auth/utils.go new file mode 100644 index 0000000000..753d91c337 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/utils.go @@ -0,0 +1,38 @@ +package main + +import ( + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "net/http" + "sort" + "strings" +) + +func sendResponse(statusCode uint32, statusCodeDetailData string, headers http.Header) error { + return proxywasm.SendHttpResponseWithDetail(statusCode, statusCodeDetailData, reconvertHeaders(headers), nil, -1) +} + +func reconvertHeaders(headers http.Header) [][2]string { + var ret [][2]string + if headers == nil { + return ret + } + for k, vs := range headers { + for _, v := range vs { + ret = append(ret, [2]string{k, v}) + } + } + sort.SliceStable(ret, func(i, j int) bool { + return ret[i][0] < ret[j][0] + }) + return ret +} + +func extractFromHeader(headers [][2]string, headerKey string) string { + for _, header := range headers { + key := header[0] + if strings.ToLower(key) == headerKey { + return strings.TrimSpace(header[1]) + } + } + return "" +} diff --git a/plugins/wasm-go/pkg/wrapper/http_wrapper.go b/plugins/wasm-go/pkg/wrapper/http_wrapper.go index 32e8f626c7..b7378f0528 100644 --- a/plugins/wasm-go/pkg/wrapper/http_wrapper.go +++ b/plugins/wasm-go/pkg/wrapper/http_wrapper.go @@ -34,6 +34,7 @@ type HttpClient interface { Delete(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error Connect(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error Trace(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Call(method, path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error } type ClusterClient[C Cluster] struct { @@ -72,6 +73,10 @@ func (c ClusterClient[C]) Trace(path string, headers [][2]string, body []byte, c return HttpCall(c.cluster, http.MethodTrace, path, headers, body, cb, timeoutMillisecond...) } +func (c ClusterClient[C]) Call(method, path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error { + return HttpCall(c.cluster, method, path, headers, body, cb, timeoutMillisecond...) +} + func HttpCall(cluster Cluster, method, path string, headers [][2]string, body []byte, callback ResponseCallback, timeoutMillisecond ...uint32) error { for i := len(headers) - 1; i >= 0; i-- {