diff --git a/CHANGELOG.md b/CHANGELOG.md index 427b4104e..ec4f97599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Unreleased changes are available as `avenga/couper:edge` container. * [`backend_request`](./docs/REFERENCE.md#backend_request) and [`backend_response`](./docs/REFERENCE.md#backend_response) variables ([#430](https://github.com/avenga/couper/pull/430)) * `beta_scope_map` attribute for the [JWT Block](./docs/REFERENCE.md#jwt-block) ([#434](https://github.com/avenga/couper/pull/434)) * `saml` [error type](./docs/ERRORS.md#error-types) ([#424](https://github.com/avenga/couper/pull/424)) + * `allowed_methods` attribute for the [API](./docs/REFERENCE.md#api-block) or [Endpoint Block](./docs/REFERENCE.md#endpoint-block) ([#444](https://github.com/avenga/couper/pull/444)) * **Changed** * Automatically add the `private` directive to the response `Cache-Control` HTTP header field value for all resources protected by [JWT](./docs/REFERENCE.md#jwt-block) ([#418](https://github.com/avenga/couper/pull/418)) diff --git a/config/api.go b/config/api.go index 3383de8f6..fe0689a67 100644 --- a/config/api.go +++ b/config/api.go @@ -12,6 +12,7 @@ var _ Inline = &API{} type API struct { ErrorHandlerSetter AccessControl []string `hcl:"access_control,optional"` + AllowedMethods []string `hcl:"allowed_methods,optional"` BasePath string `hcl:"base_path,optional"` CORS *CORS `hcl:"cors,block"` DisableAccessControl []string `hcl:"disable_access_control,optional"` diff --git a/config/configload/endpoint.go b/config/configload/endpoint.go index 91c121f48..a4dd22833 100644 --- a/config/configload/endpoint.go +++ b/config/configload/endpoint.go @@ -54,6 +54,12 @@ func refineEndpoints(definedBackends Backends, endpoints config.Endpoints, check endpointContent := bodyToContent(endpoint.Remain) + if check && endpoint.AllowedMethods != nil && len(endpoint.AllowedMethods) > 0 { + if err = validMethods(endpoint.AllowedMethods, &endpointContent.Attributes["allowed_methods"].Range); err != nil { + return err + } + } + proxies := endpointContent.Blocks.OfType(proxy) requests := endpointContent.Blocks.OfType(request) diff --git a/config/configload/load.go b/config/configload/load.go index 518a987f7..1df353b17 100644 --- a/config/configload/load.go +++ b/config/configload/load.go @@ -304,6 +304,12 @@ func LoadConfig(body hcl.Body, src []byte, filename string) (*config.Couper, err apiConfig.Name = apiBlock.Labels[0] } + if apiConfig.AllowedMethods != nil && len(apiConfig.AllowedMethods) > 0 { + if err = validMethods(apiConfig.AllowedMethods, &bodyToContent(apiConfig.Remain).Attributes["allowed_methods"].Range); err != nil { + return nil, err + } + } + err := refineEndpoints(definedBackends, apiConfig.Endpoints, true) if err != nil { return nil, err diff --git a/config/configload/validate.go b/config/configload/validate.go index 42aaec2bf..74b11428f 100644 --- a/config/configload/validate.go +++ b/config/configload/validate.go @@ -2,10 +2,15 @@ package configload import ( "fmt" + "regexp" "github.com/hashicorp/hcl/v2" ) +// https://datatracker.ietf.org/doc/html/rfc7231#section-4 +// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 +var methodRegExp = regexp.MustCompile("^[!#$%&'*+\\-.\\^_`|~0-9a-zA-Z]+$") + func validLabelName(name string, hr *hcl.Range) error { if !regexProxyRequestLabel.MatchString(name) { return hcl.Diagnostics{&hcl.Diagnostic{ @@ -53,3 +58,17 @@ func verifyBodyAttributes(content *hcl.BodyContent) error { } return nil } + +func validMethods(methods []string, hr *hcl.Range) error { + for _, method := range methods { + if !methodRegExp.MatchString(method) { + return hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "method contains invalid character(s)", + Subject: hr, + }} + } + } + + return nil +} diff --git a/config/endpoint.go b/config/endpoint.go index d0f5b0aaf..6436751ca 100644 --- a/config/endpoint.go +++ b/config/endpoint.go @@ -14,6 +14,7 @@ var _ Inline = &Endpoint{} type Endpoint struct { ErrorHandlerSetter AccessControl []string `hcl:"access_control,optional"` + AllowedMethods []string `hcl:"allowed_methods,optional"` DisableAccessControl []string `hcl:"disable_access_control,optional"` ErrorFile string `hcl:"error_file,optional"` Pattern string `hcl:"pattern,label"` diff --git a/config/runtime/server.go b/config/runtime/server.go index 98617837f..bf210b975 100644 --- a/config/runtime/server.go +++ b/config/runtime/server.go @@ -166,7 +166,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca return nil, err } - corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Spa)) + corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Spa), nil) if cerr != nil { return nil, cerr } @@ -210,7 +210,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca return nil, err } - corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Files)) + corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Files), nil) if cerr != nil { return nil, cerr } @@ -320,6 +320,20 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca } accessControl := newAC(srvConf, parentAPI) + + allowedMethods := endpointConf.AllowedMethods + if allowedMethods == nil && parentAPI != nil { + // if allowed_methods in endpoint {} not defined, try allowed_methods in parent api {} + allowedMethods = parentAPI.AllowedMethods + } + notAllowedMethodsHandler := epOpts.ErrorTemplate.WithError(errors.MethodNotAllowed) + var allowedMethodsHandler *middleware.AllowedMethodsHandler + allowedMethodsHandler, err = middleware.NewAllowedMethodsHandler(allowedMethods, protectedHandler, notAllowedMethodsHandler) + if err != nil { + return nil, err + } + protectedHandler = allowedMethodsHandler + epHandler, err = configureProtectedHandler(accessControls, confCtx, accessControl, config.NewAccessControl(endpointConf.AccessControl, endpointConf.DisableAccessControl), &protectedOptions{ @@ -333,7 +347,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca return nil, err } - corsOptions, err := middleware.NewCORSOptions(whichCORS(srvConf, parentAPI)) + corsOptions, err := middleware.NewCORSOptions(whichCORS(srvConf, parentAPI), allowedMethodsHandler.MethodAllowed) if err != nil { return nil, err } diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 8307a993b..a0bbec805 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -112,6 +112,7 @@ as json error with an error body payload. This can be customized via `error_file |`base_path`|string|-|Configures the path prefix for all requests.|⚠ Must be unique if multiple `api` blocks are defined.| `base_path = "/v1"`| | `error_file` |string|-|Location of the error file template.|-|`error_file = "./my_error_body.json"`| | `access_control` |list|-|Sets predefined [Access Control](#access-control) for `api` block context.|⚠ Inherited by nested blocks.| `access_control = ["foo"]`| +| `allowed_methods` | tuple of string | `["*"]` == `["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]` | Sets allowed methods as _default_ for all contained endpoints. Requests with a method that is not allowed result in an error response with a `405 Method Not Allowed` status. | The default value `*` can be combined with additional methods. Methods are matched case-insensitively. `Access-Control-Allow-Methods` is only sent in response to a [CORS](#cors-block) preflight request, if the method requested by `Access-Control-Request-Method` is an allowed method. | `allowed_methods = ["GET", "POST"]` or `allowed_methods = ["*", "BREW"]` | | `beta_scope` |string or object|-|Scope value required to use this API (see [error type](ERRORS.md#error-types) `beta_insufficient_scope`).|If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required".| `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }`| | `custom_log_fields` | map | - | Defines log fields for [Custom Logging](LOGS.md#custom-logging). | ⚠ Inherited by nested blocks. | - | @@ -134,6 +135,7 @@ produce an explicit or implicit client response. | `request_body_limit` | string | `64MiB` | Configures the maximum buffer size while accessing `request.form_body` or `request.json_body` content. | ⚠ Valid units are: `KiB, MiB, GiB` | `request_body_limit = "200KiB"` | | `path` | string | - | Changeable part of the upstream URL. Changes the path suffix of the outgoing request. | - | - | | `access_control` | list | - | Sets predefined [Access Control](#access-control) for `endpoint` block context. | - | `access_control = ["foo"]` | +| `allowed_methods` | tuple of string | `["*"]` == `["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]` | Sets allowed methods _overriding_ a default set in the containing `api` block. Requests with a method that is not allowed result in an error response with a `405 Method Not Allowed` status. | The default value `*` can be combined with additional methods. Methods are matched case-insensitively. `Access-Control-Allow-Methods` is only sent in response to a [CORS](#cors-block) preflight request, if the method requested by `Access-Control-Request-Method` is an allowed method. | `allowed_methods = ["GET", "POST"]` or `allowed_methods = ["*", "BREW"]` | | `beta_scope` | string or object | - | Scope value required to use this endpoint (see [error type](ERRORS.md#error-types) `beta_insufficient_scope`). | If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required". | `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }` | | `custom_log_fields` | map | - | Defines log fields for [Custom Logging](LOGS.md#custom-logging). | ⚠ Inherited by nested blocks. | - | | [Modifiers](#modifiers) | - | - | - | - | - | @@ -281,6 +283,8 @@ The `cors` block configures the CORS (Cross-Origin Resource Sharing) behavior in | `disable` | bool|`false`|Set to `true` to disable the inheritance of CORS from the [Server Block](#server-block) in [Files Block](#files-block), [SPA Block](#spa-block) and [API Block](#api-block) contexts.|-|-| | `max_age` |[duration](#duration)|-|Indicates the time the information provided by the `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` response HTTP header fields.|⚠ Can be cached|`max_age = "1h"`| +**Note:** `Access-Control-Allow-Methods` is only sent in response to a CORS preflight request, if the method requested by `Access-Control-Request-Method` is an allowed method (see the `allowed_method` attribute for [`api`](#api-block) or [`endpoint`](#endpoint-block) blocks). + ### OAuth2 CC Block The `oauth2` block in the [Backend Block](#backend-block) context configures the OAuth2 Client Credentials flow to request a bearer token for the backend request. diff --git a/errors/couper.go b/errors/couper.go index 0735197f7..896ce451b 100644 --- a/errors/couper.go +++ b/errors/couper.go @@ -18,6 +18,7 @@ var ( Endpoint = &Error{synopsis: "endpoint error", kinds: []string{"endpoint"}, httpStatus: http.StatusBadGateway} Evaluation = &Error{synopsis: "expression evaluation error", kinds: []string{"evaluation"}, httpStatus: http.StatusInternalServerError} Configuration = &Error{synopsis: "configuration error", kinds: []string{"configuration"}, httpStatus: http.StatusInternalServerError} + MethodNotAllowed = &Error{synopsis: "method not allowed error", httpStatus: http.StatusMethodNotAllowed} Proxy = &Error{synopsis: "proxy error", httpStatus: http.StatusBadGateway} Request = &Error{synopsis: "request error", httpStatus: http.StatusBadGateway} RouteNotFound = &Error{synopsis: "route not found error", httpStatus: http.StatusNotFound} diff --git a/handler/middleware/allowed_methods.go b/handler/middleware/allowed_methods.go new file mode 100644 index 000000000..6f6dfc61e --- /dev/null +++ b/handler/middleware/allowed_methods.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "net/http" + "strings" +) + +var defaultAllowedMethods = []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, +} + +var _ http.Handler = &AllowedMethodsHandler{} + +type AllowedMethodsHandler struct { + allowedMethods map[string]struct{} + allowedHandler http.Handler + notAllowedHandler http.Handler +} + +type methodAllowedFunc func(string) bool + +func NewAllowedMethodsHandler(allowedMethods []string, allowedHandler, notAllowedHandler http.Handler) (*AllowedMethodsHandler, error) { + amh := &AllowedMethodsHandler{ + allowedMethods: make(map[string]struct{}), + allowedHandler: allowedHandler, + notAllowedHandler: notAllowedHandler, + } + if allowedMethods == nil { + allowedMethods = defaultAllowedMethods + } + for _, method := range allowedMethods { + if method == "*" { + for _, m := range defaultAllowedMethods { + amh.allowedMethods[m] = struct{}{} + } + } else { + method = strings.ToUpper(method) + amh.allowedMethods[method] = struct{}{} + } + } + + return amh, nil +} + +func (a *AllowedMethodsHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if _, ok := a.allowedMethods[req.Method]; !ok { + a.notAllowedHandler.ServeHTTP(rw, req) + return + } + + a.allowedHandler.ServeHTTP(rw, req) +} + +func (a *AllowedMethodsHandler) MethodAllowed(method string) bool { + method = strings.TrimSpace(strings.ToUpper(method)) + _, ok := a.allowedMethods[method] + return ok +} + +func (a *AllowedMethodsHandler) Child() http.Handler { + return a.allowedHandler +} diff --git a/handler/middleware/cors.go b/handler/middleware/cors.go index e3894d27e..8947f978a 100644 --- a/handler/middleware/cors.go +++ b/handler/middleware/cors.go @@ -23,9 +23,10 @@ type CORSOptions struct { AllowedOrigins []string AllowCredentials bool MaxAge string + methodAllowed methodAllowedFunc } -func NewCORSOptions(cors *config.CORS) (*CORSOptions, error) { +func NewCORSOptions(cors *config.CORS, methodAllowed methodAllowedFunc) (*CORSOptions, error) { if cors == nil { return nil, nil } @@ -49,6 +50,7 @@ func NewCORSOptions(cors *config.CORS) (*CORSOptions, error) { AllowedOrigins: allowedOrigins, AllowCredentials: cors.AllowCredentials, MaxAge: corsMaxAge, + methodAllowed: methodAllowed, }, nil } @@ -128,7 +130,9 @@ func (c *CORS) setCorsRespHeaders(headers http.Header, req *http.Request) { // Reflect request header value acrm := req.Header.Get("Access-Control-Request-Method") if acrm != "" { - headers.Set("Access-Control-Allow-Methods", acrm) + if c.options.methodAllowed == nil || c.options.methodAllowed(acrm) { + headers.Set("Access-Control-Allow-Methods", acrm) + } headers.Add("Vary", "Access-Control-Request-Method") } // Reflect request header value diff --git a/handler/middleware/cors_test.go b/handler/middleware/cors_test.go index b560fb890..0e50c9f65 100644 --- a/handler/middleware/cors_test.go +++ b/handler/middleware/cors_test.go @@ -364,6 +364,11 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { } }) + var methodAllowed methodAllowedFunc + methodAllowed = func(method string) bool { + return method == http.MethodPost + } + tests := []struct { name string corsOptions *CORSOptions @@ -373,7 +378,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }{ { "specific origin, with ACRM", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Method": "POST", @@ -387,9 +392,25 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, []string{"Origin", "Access-Control-Request-Method"}, }, + { + "specific origin, with ACRM, method not allowed", + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed}, + map[string]string{ + "Origin": "https://www.example.com", + "Access-Control-Request-Method": "PUT", + }, + map[string]string{ + "Access-Control-Allow-Origin": "https://www.example.com", + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Allow-Credentials": "", + "Access-Control-Max-Age": "", + }, + []string{"Origin", "Access-Control-Request-Method"}, + }, { "specific origin, with ACRH", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Headers": "X-Foo, X-Bar", @@ -405,7 +426,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "specific origin, with ACRM, ACRH", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Method": "POST", @@ -422,7 +443,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "specific origin, with ACRM, credentials", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Method": "POST", @@ -438,7 +459,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "specific origin, with ACRM, max-age", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, MaxAge: "3600"}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, MaxAge: "3600", methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Method": "POST", @@ -454,7 +475,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "any origin, with ACRM", - &CORSOptions{AllowedOrigins: []string{"*"}}, + &CORSOptions{AllowedOrigins: []string{"*"}, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Method": "POST", @@ -470,7 +491,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "any origin, with ACRM, credentials", - &CORSOptions{AllowedOrigins: []string{"*"}, AllowCredentials: true}, + &CORSOptions{AllowedOrigins: []string{"*"}, AllowCredentials: true, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.com", "Access-Control-Request-Method": "POST", @@ -486,7 +507,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "origin mismatch", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.org", "Access-Control-Request-Method": "POST", @@ -502,7 +523,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) { }, { "origin mismatch, credentials", - &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true}, + &CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true, methodAllowed: methodAllowed}, map[string]string{ "Origin": "https://www.example.org", "Access-Control-Request-Method": "POST", diff --git a/main_test.go b/main_test.go index a55776f39..1d91a1e4a 100644 --- a/main_test.go +++ b/main_test.go @@ -42,6 +42,9 @@ func Test_realmain(t *testing.T) { {"non-existent log level via env /w file", []string{"couper", "run", "-f", base + "/log_altered.hcl"}, []string{"COUPER_LOG_LEVEL=test"}, `level=error msg="configuration error: missing 'server' block" build=dev`, 1}, {"-f w/o file", []string{"couper", "run", "-f"}, nil, `level=error msg="flag needs an argument: -f" build=dev`, 1}, {"undefined AC", []string{"couper", "run", "-f", base + "/04_couper.hcl"}, nil, `level=error msg="accessControl is not defined: undefined" build=dev`, 1}, + {"empty string in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/13_couper.hcl"}, nil, `level=error msg="13_couper.hcl:3,5-27: method contains invalid character(s); " build=dev`, 1}, + {"invalid method in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/14_couper.hcl"}, nil, `level=error msg="14_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1}, + {"invalid method in allowed_methods in api", []string{"couper", "run", "-f", base + "/15_couper.hcl"}, nil, `level=error msg="15_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1}, } for _, tt := range tests { t.Run(tt.name, func(subT *testing.T) { diff --git a/server/http.go b/server/http.go index 65bbf13c1..7f59cc5f8 100644 --- a/server/http.go +++ b/server/http.go @@ -72,7 +72,7 @@ func New(cmdCtx, evalCtx context.Context, log logrus.FieldLogger, settings *conf muxersList := make(muxers) for host, muxOpts := range hosts { mux := NewMux(muxOpts) - mux.MustAddRoute(http.MethodGet, settings.HealthPath, handler.NewHealthCheck(settings.HealthPath, shutdownCh)) + mux.mustAddRoute(mux.endpointRoot, []string{http.MethodGet}, settings.HealthPath, handler.NewHealthCheck(settings.HealthPath, shutdownCh), true) muxersList[host] = mux } diff --git a/server/http_integration_test.go b/server/http_integration_test.go index f89c8793b..69cf6cbd3 100644 --- a/server/http_integration_test.go +++ b/server/http_integration_test.go @@ -3131,7 +3131,7 @@ func TestAPICatchAll(t *testing.T) { {"exists, CORS pre-flight", "/v5/exists", http.MethodOptions, http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}}, http.StatusNoContent, ""}, {"not-exist, authorized", "/v5/not-exist", http.MethodGet, http.Header{"Authorization": []string{"Basic OmFzZGY="}}, http.StatusNotFound, "route not found error"}, {"not-exist, unauthorized", "/v5/not-exist", http.MethodGet, http.Header{}, http.StatusUnauthorized, "access control error: ba1: credentials required"}, - {"not-exist, non-standard method, authorized", "/v5/not-exist", "BREW", http.Header{"Authorization": []string{"Basic OmFzZGY="}}, http.StatusNotFound, "route not found error"}, + {"not-exist, non-standard method, authorized", "/v5/not-exist", "BREW", http.Header{"Authorization": []string{"Basic OmFzZGY="}}, http.StatusMethodNotAllowed, "method not allowed error"}, {"not-exist, non-standard method, unauthorized", "/v5/not-exist", "BREW", http.Header{}, http.StatusUnauthorized, "access control error: ba1: credentials required"}, {"not-exist, CORS pre-flight", "/v5/not-exist", http.MethodOptions, http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}}, http.StatusNoContent, ""}, } { @@ -4515,6 +4515,179 @@ func TestOIDCDefaultNonceFunctions(t *testing.T) { } } +func TestAllowedMethods(t *testing.T) { + client := newClient() + + confPath := "testdata/integration/config/11_couper.hcl" + shutdown, logHook := newCouper(confPath, test.New(t)) + defer shutdown() + + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZSI6ImZvbyJ9.7zkwmXTmzFTKHC0Qnpw7uQCcacogWUvi_JU56uWJlkw" + + type testCase struct { + name string + method string + path string + requestHeaders http.Header + status int + couperError string + } + + for _, tc := range []testCase{ + {"path not found, authorized", http.MethodGet, "/api1/not-found", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusNotFound, "route not found error"}, + + {"unrestricted, authorized, GET", http.MethodGet, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"unrestricted, authorized, HEAD", http.MethodHead, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"unrestricted, authorized, POST", http.MethodPost, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"unrestricted, authorized, PUT", http.MethodPut, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"unrestricted, authorized, PATCH", http.MethodPatch, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"unrestricted, authorized, DELETE", http.MethodDelete, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"}, + {"unrestricted, authorized, OPTIONS", http.MethodOptions, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"}, + {"unrestricted, authorized, CONNECT", http.MethodConnect, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"unrestricted, authorized, TRACE", http.MethodTrace, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"unrestricted, authorized, BREW", "BREW", "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"unrestricted, unauthorized, GET", http.MethodGet, "/api1/unrestricted", http.Header{}, http.StatusUnauthorized, "access control error"}, + {"unrestricted, unauthorized, BREW", "BREW", "/api1/unrestricted", http.Header{}, http.StatusUnauthorized, "access control error"}, + {"unrestricted, CORS preflight", http.MethodOptions, "/api1/unrestricted", http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}, "Access-Control-Request-Headers": []string{"Authorization"}}, http.StatusNoContent, ""}, + + {"restricted, authorized, GET", http.MethodGet, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"restricted, authorized, HEAD", http.MethodHead, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted, authorized, POST", http.MethodPost, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""}, + {"restricted, authorized, PUT", http.MethodPut, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted, authorized, PATCH", http.MethodPatch, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted, authorized, DELETE", http.MethodDelete, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"}, + {"restricted, authorized, OPTIONS", http.MethodOptions, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted, authorized, CONNECT", http.MethodConnect, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted, authorized, TRACE", http.MethodTrace, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted, authorized, BREW", "BREW", "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"}, // BREW not supported by scope AC + {"restricted, CORS preflight", http.MethodOptions, "/api1/restricted", http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}, "Access-Control-Request-Headers": []string{"Authorization"}}, http.StatusNoContent, ""}, + + {"wildcard, GET", http.MethodGet, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, HEAD", http.MethodHead, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, POST", http.MethodPost, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, PUT", http.MethodPut, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, PATCH", http.MethodPatch, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, DELETE", http.MethodDelete, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, OPTIONS", http.MethodOptions, "/api1/wildcard", http.Header{}, http.StatusOK, ""}, + {"wildcard, CONNECT", http.MethodConnect, "/api1/wildcard", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"wildcard, TRACE", http.MethodTrace, "/api1/wildcard", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"wildcard, BREW", "BREW", "/api1/wildcard", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + + {"wildcard and more, GET", http.MethodGet, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, HEAD", http.MethodHead, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, POST", http.MethodPost, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, PUT", http.MethodPut, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, PATCH", http.MethodPatch, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, DELETE", http.MethodDelete, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, OPTIONS", http.MethodOptions, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + {"wildcard and more, CONNECT", http.MethodConnect, "/api1/wildcardAndMore", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"wildcard and more, TRACE", http.MethodTrace, "/api1/wildcardAndMore", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"wildcard and more, BREW", "BREW", "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""}, + + {"blocked, GET", http.MethodGet, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, HEAD", http.MethodHead, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, POST", http.MethodPost, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, PUT", http.MethodPut, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, PATCH", http.MethodPatch, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, DELETE", http.MethodDelete, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, OPTIONS", http.MethodOptions, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, CONNECT", http.MethodConnect, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, TRACE", http.MethodTrace, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"blocked, BREW", "BREW", "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + + {"restricted methods override, GET", http.MethodGet, "/api2/restricted", http.Header{}, http.StatusOK, ""}, + {"restricted methods override, HEAD", http.MethodHead, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted methods override, POST", http.MethodPost, "/api2/restricted", http.Header{}, http.StatusOK, ""}, + {"restricted methods override, PUT", http.MethodPut, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted methods override, PATCH", http.MethodPatch, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted methods override, DELETE", http.MethodDelete, "/api2/restricted", http.Header{}, http.StatusOK, ""}, + {"restricted methods override, OPTIONS", http.MethodOptions, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted methods override, CONNECT", http.MethodConnect, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted methods override, TRACE", http.MethodTrace, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted methods override, BREW", "BREW", "/api2/restricted", http.Header{}, http.StatusOK, ""}, + + {"restricted by api only, GET", http.MethodGet, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, HEAD", http.MethodHead, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, POST", http.MethodPost, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, PUT", http.MethodPut, "/api2/restrictedByApiOnly", http.Header{}, http.StatusOK, ""}, + {"restricted by api only, PATCH", http.MethodPatch, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, DELETE", http.MethodDelete, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, OPTIONS", http.MethodOptions, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, CONNECT", http.MethodConnect, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, TRACE", http.MethodTrace, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + {"restricted by api only, BREW", "BREW", "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"}, + } { + t.Run(tc.name, func(subT *testing.T) { + helper := test.New(subT) + logHook.Reset() + req, err := http.NewRequest(tc.method, "http://example.com:8080"+tc.path, nil) + helper.Must(err) + req.Header = tc.requestHeaders + + res, err := client.Do(req) + helper.Must(err) + + if tc.status != res.StatusCode { + subT.Errorf("Unexpected status code given; want: %d; got: %d", tc.status, res.StatusCode) + } + + couperError := res.Header.Get("Couper-Error") + if tc.couperError != couperError { + subT.Errorf("Unexpected couper-error given; want: %q; got: %q", tc.couperError, couperError) + } + }) + } +} + +func TestAllowedMethodsCORS_Preflight(t *testing.T) { + client := newClient() + + confPath := "testdata/integration/config/11_couper.hcl" + shutdown, logHook := newCouper(confPath, test.New(t)) + defer shutdown() + + type testCase struct { + name string + path string + requestMethod string + status int + allowMethods []string + couperError string + } + + for _, tc := range []testCase{ + {"unrestricted, CORS preflight, POST allowed", "/api1/unrestricted", http.MethodPost, http.StatusNoContent, []string{"POST"}, ""}, + {"restricted, CORS preflight, POST allowed", "/api1/restricted", http.MethodPost, http.StatusNoContent, []string{"POST"}, ""}, // CORS preflight ok even if OPTIONS is otherwise not allowed + {"restricted, CORS preflight, PUT not allowed", "/api1/restricted", http.MethodPut, http.StatusNoContent, nil, ""}, + } { + t.Run(tc.name, func(subT *testing.T) { + helper := test.New(subT) + logHook.Reset() + req, err := http.NewRequest(http.MethodOptions, "http://example.com:8080"+tc.path, nil) + helper.Must(err) + req.Header.Set("Origin", "https://www.example.com") + req.Header.Set("Access-Control-Request-Method", tc.requestMethod) + + res, err := client.Do(req) + helper.Must(err) + + if tc.status != res.StatusCode { + subT.Errorf("Unexpected status code given; want: %d; got: %d", tc.status, res.StatusCode) + } + + allowMethods := res.Header.Values("Access-Control-Allow-Methods") + if !cmp.Equal(tc.allowMethods, allowMethods) { + subT.Errorf(cmp.Diff(tc.allowMethods, allowMethods)) + } + + couperError := res.Header.Get("Couper-Error") + if tc.couperError != couperError { + subT.Errorf("Unexpected couper-error given; want: %q; got: %q", tc.couperError, couperError) + } + }) + } +} + func TestEndpoint_ResponseNilEvaluation(t *testing.T) { client := newClient() diff --git a/server/mux.go b/server/mux.go index 41bfa9427..a95cac2ec 100644 --- a/server/mux.go +++ b/server/mux.go @@ -29,16 +29,6 @@ type Mux struct { spaRoot *pathpattern.Node } -var allowedMethods = []string{ - http.MethodGet, - http.MethodHead, - http.MethodPost, - http.MethodPut, - http.MethodPatch, - http.MethodDelete, - http.MethodOptions, -} - var fileMethods = []string{ http.MethodGet, http.MethodHead, @@ -67,7 +57,7 @@ func NewMux(options *runtime.MuxOptions) *Mux { for path, h := range opts.EndpointRoutes { // TODO: handle method option per endpoint configuration - mux.mustAddRoute(mux.endpointRoot, allowedMethods, path, h, true) + mux.mustAddRoute(mux.endpointRoot, nil, path, h, true) } for path, h := range opts.FileRoutes { @@ -81,26 +71,6 @@ func NewMux(options *runtime.MuxOptions) *Mux { return mux } -func (m *Mux) MustAddRoute(method, path string, handler http.Handler) *Mux { - methods := allowedMethods[:] - if method != "*" { - um := strings.ToUpper(method) - var allowed bool - for _, am := range allowedMethods { - if um == am { - allowed = true - break - } - } - if !allowed { - panic(fmt.Errorf("method not allowed: %q, path: %q", um, path)) - } - - methods = []string{um} - } - return m.mustAddRoute(m.endpointRoot, methods, path, handler, false) -} - func (m *Mux) mustAddRoute(root *pathpattern.Node, methods []string, path string, handler http.Handler, forEndpoint bool) *Mux { if forEndpoint && strings.HasSuffix(path, wildcardSearch) { route := mustCreateNode(root, handler, "", path) @@ -108,6 +78,14 @@ func (m *Mux) mustAddRoute(root *pathpattern.Node, methods []string, path string return m } + if methods == nil { + // EndpointRoutes allowed methods are handled by handler + route := mustCreateNode(root, handler, "", path) + m.handler[route] = handler + + return m + } + for _, method := range methods { route := mustCreateNode(root, handler, method, path) m.handler[route] = handler diff --git a/server/testdata/integration/config/11_couper.hcl b/server/testdata/integration/config/11_couper.hcl new file mode 100644 index 000000000..d60205109 --- /dev/null +++ b/server/testdata/integration/config/11_couper.hcl @@ -0,0 +1,94 @@ +server { + api { + base_path = "/api1" + + cors { + allowed_origins = ["*"] + } + + endpoint "/unrestricted" { + access_control = ["token"] + beta_scope = { + get = "foo" + head = "foo" + post = "foo" + put = "foo" + patch = "foo" + # no delete, no options, no trace + brew = "foo" + } + response { + body = "a" + } + } + + endpoint "/restricted" { + access_control = ["token"] + allowed_methods = ["GET", "Post", "delete", "BREW"] + beta_scope = { + get = "foo" + head = "foo" + post = "foo" + put = "foo" + patch = "foo" + # no delete, no options, no trace + brew = "foo" + } + + response { + body = "a" + } + } + + endpoint "/wildcard" { + allowed_methods = ["*"] + + response { + body = "a" + } + } + + endpoint "/wildcardAndMore" { + allowed_methods = ["get", "*", "PuT", "brew"] + + response { + body = "a" + } + } + + endpoint "/blocked" { + allowed_methods = [] + + response { + body = "a" + } + } + } + + api { + base_path = "/api2" + allowed_methods = ["PUT"] + + endpoint "/restricted" { + allowed_methods = ["GET", "Post", "delete", "BREW"] + + response { + body = "a" + } + } + + endpoint "/restrictedByApiOnly" { + response { + body = "a" + } + } + } +} + +definitions { + jwt "token" { + signature_algorithm = "HS256" + key = "asdf" + beta_scope_claim = "scope" + } +} diff --git a/server/testdata/settings/13_couper.hcl b/server/testdata/settings/13_couper.hcl new file mode 100644 index 000000000..774e67bf7 --- /dev/null +++ b/server/testdata/settings/13_couper.hcl @@ -0,0 +1,8 @@ +server { + endpoint "/" { + allowed_methods = [""] + response { + body = "a" + } + } +} diff --git a/server/testdata/settings/14_couper.hcl b/server/testdata/settings/14_couper.hcl new file mode 100644 index 000000000..fa3315c80 --- /dev/null +++ b/server/testdata/settings/14_couper.hcl @@ -0,0 +1,8 @@ +server { + endpoint "/" { + allowed_methods = ["in valid"] + response { + body = "a" + } + } +} diff --git a/server/testdata/settings/15_couper.hcl b/server/testdata/settings/15_couper.hcl new file mode 100644 index 000000000..b05fe79f1 --- /dev/null +++ b/server/testdata/settings/15_couper.hcl @@ -0,0 +1,5 @@ +server { + api { + allowed_methods = ["in valid"] + } +}