diff --git a/conformance/tests/httproute-cors.go b/conformance/tests/httproute-cors.go new file mode 100644 index 0000000000..6c2ad8719c --- /dev/null +++ b/conformance/tests/httproute-cors.go @@ -0,0 +1,305 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, HTTPRouteCORS) +} + +var HTTPRouteCORS = suite.ConformanceTest{ + ShortName: "HTTPRouteCORS", + Description: "An HTTPRoute with CORS filter should allow CORS requests from specified origins", + Manifests: []string{"tests/httproute-cors.yaml"}, + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportHTTPRoute, + features.SupportHTTPRouteCORS, + }, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + routeNN1 := types.NamespacedName{Name: "cors-1", Namespace: ns} + routeNN2 := types.NamespacedName{Name: "cors-2", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN1, routeNN2) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN1, gwNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN2, gwNN) + + testCases := []http.ExpectedResponse{ + { + TestCaseName: "CORS preflight request from an exact matching origin should be allowed", + Request: http.Request{ + Path: "/cors-1", + Method: "OPTIONS", + Headers: map[string]string{ + "Origin": "https://www.foo.com", + "access-control-request-method": "GET", + "access-control-request-headers": "x-header-1, x-header-2", + }, + }, + // Set the expected request properties and namespace to empty strings. + // This is a workaround to avoid the test failure. + // The response body is empty because the request is a preflight request, + // so we can't get the request properties from the echoserver. + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "", + Method: "OPTIONS", + Path: "", + Headers: nil, + }, + }, + Namespace: "", + Response: http.Response{ + StatusCodes: []int{200, 204}, + HeadersWithMultipleValues: map[string][]string{ + "access-control-allow-origin": {"https://www.foo.com"}, + "access-control-allow-methods": { + "GET, OPTIONS", + "OPTIONS, GET", + }, + "access-control-allow-headers": { + "x-header-1, x-header-2", + "x-header-2, x-header-1", + }, + "access-control-expose-headers": { + "x-header-3, x-header-4", + "x-header-4, x-header-3", + }, + "access-control-max-age": {"3600"}, + "access-control-allow-credentials": {"true"}, + }, + // Ignore whitespace when comparing the response headers. This is because some + // implementations add a space after each comma, and some don't. Both are valid. + IgnoreWhitespace: true, + }, + }, + { + TestCaseName: "CORS preflight request from a wildcard matching origin should be allowed", + Request: http.Request{ + Path: "/cors-1", + Method: "OPTIONS", + Headers: map[string]string{ + "Origin": "https://www.bar.com", + "access-control-request-method": "GET", + "access-control-request-headers": "x-header-1, x-header-2", + }, + }, + // Set the expected request properties and namespace to empty strings. + // This is a workaround to avoid the test failure. + // The response body is empty because the request is a preflight request, + // so we can't get the request properties from the echoserver. + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "", + Method: "OPTIONS", + Path: "", + Headers: nil, + }, + }, + Namespace: "", + Response: http.Response{ + StatusCode: 200, + HeadersWithMultipleValues: map[string][]string{ + "access-control-allow-origin": {"https://www.bar.com"}, + "access-control-allow-methods": { + "GET, OPTIONS", + "OPTIONS, GET", + }, + "access-control-allow-headers": { + "x-header-1, x-header-2", + "x-header-2, x-header-1", + }, + "access-control-expose-headers": { + "x-header-3, x-header-4", + "x-header-4, x-header-3", + }, + "access-control-max-age": {"3600"}, + "access-control-allow-credentials": {"true"}, + }, + // Ignore whitespace when comparing the response headers. This is because some + // implementations add a space after each comma, and some don't. Both are valid. + IgnoreWhitespace: true, + }, + }, + { + TestCaseName: "CORS preflight request from a non-matching origin should not be allowed", + Request: http.Request{ + Path: "/cors-1", + Method: "OPTIONS", + Headers: map[string]string{ + "Origin": "https://foobar.com", + "access-control-request-method": "GET", + }, + }, + // Set the expected request properties and namespace to empty strings. + // This is a workaround to avoid the test failure. + // The response body is empty because the request is a preflight request, + // so we can't get the request properties from the echoserver. + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "", + Method: "OPTIONS", + Path: "", + Headers: nil, + }, + }, + Namespace: "", + Response: http.Response{ + StatusCodes: []int{200, 204, 403}, + AbsentHeaders: []string{ + "access-control-allow-origin", + }, + }, + }, + { + TestCaseName: "Simple request from an exact matching origin should be allowed", + Namespace: ns, + Request: http.Request{ + Path: "/cors-1", + Method: "GET", + Headers: map[string]string{ + "Origin": "https://www.foo.com", + "access-control-request-method": "GET", + "access-control-request-headers": "x-header-1, x-header-2", + }, + }, + Response: http.Response{ + StatusCodes: []int{200, 204}, + Headers: map[string]string{ + "access-control-allow-origin": "https://www.foo.com", + }, + }, + }, + { + TestCaseName: "Simple request from a wildcard matching origin should be allowed", + Namespace: ns, + Request: http.Request{ + Path: "/cors-1", + Method: "GET", + Headers: map[string]string{ + "Origin": "https://www.bar.com", + "access-control-request-method": "GET", + "access-control-request-headers": "x-header-1, x-header-2", + }, + }, + Response: http.Response{ + StatusCodes: []int{200, 204}, + Headers: map[string]string{ + "access-control-allow-origin": "https://www.bar.com", + }, + }, + }, + { + TestCaseName: "Simple request from a non-matching origin should not be allowed", + Namespace: ns, + Request: http.Request{ + Path: "/cors-1", + Method: "GET", + Headers: map[string]string{ + "Origin": "https://foobar.com", + "access-control-request-method": "GET", + }, + }, + Response: http.Response{ + AbsentHeaders: []string{ + "access-control-allow-origin", + }, + }, + }, + { + TestCaseName: "CORS preflight request with POST method should be allowed by allowMethods with wildcard", + Request: http.Request{ + Path: "/cors-2", + Method: "OPTIONS", + Headers: map[string]string{ + "Origin": "https://www.foo.com", + "access-control-request-method": "POST", + }, + }, + // Set the expected request properties and namespace to empty strings. + // This is a workaround to avoid the test failure. + // The response body is empty because the request is a preflight request, + // so we can't get the request properties from the echoserver. + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "", + Method: "OPTIONS", + Path: "", + Headers: nil, + }, + }, + Namespace: "", + Response: http.Response{ + StatusCodes: []int{200, 204}, + Headers: map[string]string{ + "access-control-allow-origin": "https://www.foo.com", + "access-control-allow-methods": "POST", + }, + }, + }, + { + TestCaseName: "CORS preflight request should not receive access-control-allow-credentials header without access-control-allow-credentials set to true", + Request: http.Request{ + Path: "/cors-2", + Method: "OPTIONS", + Headers: map[string]string{ + "Origin": "https://www.foo.com", + "access-control-request-method": "POST", + }, + }, + // Set the expected request properties and namespace to empty strings. + // This is a workaround to avoid the test failure. + // The response body is empty because the request is a preflight request, + // so we can't get the request properties from the echoserver. + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "", + Method: "OPTIONS", + Path: "", + Headers: nil, + }, + }, + Namespace: "", + Response: http.Response{ + AbsentHeaders: []string{ + "access-control-allow-credentials", + }, + }, + }, + } + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + tc := testCases[i] + t.Run(tc.GetTestCaseName(i), func(t *testing.T) { + t.Parallel() + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, tc) + }) + } + }, +} diff --git a/conformance/tests/httproute-cors.yaml b/conformance/tests/httproute-cors.yaml new file mode 100644 index 0000000000..fab5cc79f3 --- /dev/null +++ b/conformance/tests/httproute-cors.yaml @@ -0,0 +1,56 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: cors-1 + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - filters: + - type: CORS + cors: + allowOrigins: + - "https://www.foo.com" + - "https://*.bar.com" + allowMethods: + - GET + - OPTIONS + allowHeaders: + - "x-header-1" + - "x-header-2" + exposeHeaders: + - "x-header-3" + - "x-header-4" + allowCredentials: true + maxAge: 3600 + matches: + - path: + type: PathPrefix + value: /cors-1 + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: cors-2 # test CORS with allowMethods: ["*"] and without allowCredentials=true + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - filters: + - type: CORS + cors: + allowOrigins: + - "https://www.foo.com" + allowMethods: ["*"] + matches: + - path: + type: PathPrefix + value: /cors-2 + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/conformance/utils/http/http.go b/conformance/utils/http/http.go index 6cd68dd084..94002b92c0 100644 --- a/conformance/utils/http/http.go +++ b/conformance/utils/http/http.go @@ -93,11 +93,15 @@ type ExpectedRequest struct { // Response defines expected properties of a response from a backend. type Response struct { // Deprecated: Use StatusCodes instead, which supports matching against multiple status codes. - StatusCode int - StatusCodes []int - Headers map[string]string - AbsentHeaders []string - Protocol string + StatusCode int + StatusCodes []int + Headers map[string]string + HeadersWithMultipleValues map[string][]string + AbsentHeaders []string + Protocol string + // IgnoreWhitespace will cause whitespace to be ignored when comparing the respond + // header values. + IgnoreWhitespace bool } type BackendRef struct { @@ -321,62 +325,115 @@ func CompareRoundTrip(t *testing.T, req *roundtripper.Request, cReq *roundtrippe return fmt.Errorf("expected protocol to be %s, got %s", expected.Response.Protocol, cRes.Protocol) } - if cRes.StatusCode == 200 { - // The request expected to arrive at the backend is - // the same as the request made, unless otherwise - // specified. - if expected.ExpectedRequest == nil { - expected.ExpectedRequest = &ExpectedRequest{Request: expected.Request} - } + if cRes.StatusCode == 200 || cRes.StatusCode == 204 { + if cRes.StatusCode == 200 { + // The request expected to arrive at the backend is + // the same as the request made, unless otherwise + // specified. + if expected.ExpectedRequest == nil { + expected.ExpectedRequest = &ExpectedRequest{Request: expected.Request} + } - if expected.ExpectedRequest.Method == "" { - expected.ExpectedRequest.Method = "GET" - } + if expected.ExpectedRequest.Method == "" { + expected.ExpectedRequest.Method = "GET" + } - if expected.ExpectedRequest.Host != "" && expected.ExpectedRequest.Host != cReq.Host { - return fmt.Errorf("expected host to be %s, got %s", expected.ExpectedRequest.Host, cReq.Host) - } + if expected.ExpectedRequest.Host != "" && expected.ExpectedRequest.Host != cReq.Host { + return fmt.Errorf("expected host to be %s, got %s", expected.ExpectedRequest.Host, cReq.Host) + } - if expected.ExpectedRequest.Path != cReq.Path { - return fmt.Errorf("expected path to be %s, got %s", expected.ExpectedRequest.Path, cReq.Path) - } - if expected.ExpectedRequest.Method != cReq.Method { - return fmt.Errorf("expected method to be %s, got %s", expected.ExpectedRequest.Method, cReq.Method) - } - if expected.Namespace != cReq.Namespace { - return fmt.Errorf("expected namespace to be %s, got %s", expected.Namespace, cReq.Namespace) - } - if expected.ExpectedRequest.Headers != nil { - if cReq.Headers == nil { - return fmt.Errorf("no headers captured, expected %v", len(expected.ExpectedRequest.Headers)) + if expected.ExpectedRequest.Path != cReq.Path { + return fmt.Errorf("expected path to be %s, got %s", expected.ExpectedRequest.Path, cReq.Path) } - for name, val := range cReq.Headers { - cReq.Headers[strings.ToLower(name)] = val + if expected.ExpectedRequest.Method != cReq.Method { + return fmt.Errorf("expected method to be %s, got %s", expected.ExpectedRequest.Method, cReq.Method) } - for name, expectedVal := range expected.ExpectedRequest.Headers { - actualVal, ok := cReq.Headers[strings.ToLower(name)] - if !ok { - return fmt.Errorf("expected %s header to be set, actual headers: %v", name, cReq.Headers) - } else if strings.Join(actualVal, ",") != expectedVal { - return fmt.Errorf("expected %s header to be set to %s, got %s", name, expectedVal, strings.Join(actualVal, ",")) + if expected.Namespace != cReq.Namespace { + return fmt.Errorf("expected namespace to be %s, got %s", expected.Namespace, cReq.Namespace) + } + if expected.ExpectedRequest.Headers != nil { + if cReq.Headers == nil { + return fmt.Errorf("no headers captured, expected %v", len(expected.ExpectedRequest.Headers)) + } + for name, val := range cReq.Headers { + cReq.Headers[strings.ToLower(name)] = val + } + for name, expectedVal := range expected.ExpectedRequest.Headers { + actualVal, ok := cReq.Headers[strings.ToLower(name)] + if !ok { + return fmt.Errorf("expected %s header to be set, actual headers: %v", name, cReq.Headers) + } else if strings.Join(actualVal, ",") != expectedVal { + return fmt.Errorf("expected %s header to be set to %s, got %s", name, expectedVal, strings.Join(actualVal, ",")) + } } } + + // Verify that headers expected *not* to be present on the + // request are actually not present. + if len(expected.ExpectedRequest.AbsentHeaders) > 0 { + for name, val := range cReq.Headers { + cReq.Headers[strings.ToLower(name)] = val + } + + for _, name := range expected.ExpectedRequest.AbsentHeaders { + val, ok := cReq.Headers[strings.ToLower(name)] + if ok { + return fmt.Errorf("expected %s header to not be set, got %s", name, val) + } + } + } + + if !strings.HasPrefix(cReq.Pod, expected.Backend) { + return fmt.Errorf("expected pod name to start with %s, got %s", expected.Backend, cReq.Pod) + } } - if expected.Response.Headers != nil { + if expected.Response.Headers != nil || expected.Response.HeadersWithMultipleValues != nil { if cRes.Headers == nil { - return fmt.Errorf("no headers captured, expected %v", len(expected.ExpectedRequest.Headers)) + return fmt.Errorf("no headers captured, expected %v", len(expected.Response.Headers)) } for name, val := range cRes.Headers { cRes.Headers[strings.ToLower(name)] = val } - for name, expectedVal := range expected.Response.Headers { - actualVal, ok := cRes.Headers[strings.ToLower(name)] - if !ok { - return fmt.Errorf("expected %s header to be set, actual headers: %v", name, cRes.Headers) - } else if strings.Join(actualVal, ",") != expectedVal { - return fmt.Errorf("expected %s header to be set to %s, got %s", name, expectedVal, strings.Join(actualVal, ",")) + if expected.Response.HeadersWithMultipleValues != nil { + for name, expectedVals := range expected.Response.HeadersWithMultipleValues { + actualVal, ok := cRes.Headers[strings.ToLower(name)] + if !ok { + return fmt.Errorf("expected %s header to be set, actual headers: %v", name, cRes.Headers) + } + actualValStr := strings.Join(actualVal, ",") + if expected.Response.IgnoreWhitespace { + actualValStr = strings.ReplaceAll(actualValStr, " ", "") + for i := range expectedVals { + expectedVals[i] = strings.ReplaceAll(expectedVals[i], " ", "") + } + } + matched := false + for _, expectedVal := range expectedVals { + if actualValStr == expectedVal { + matched = true + break + } + } + if !matched { + return fmt.Errorf("expected %s header to be set to one of %v, got %s", name, expectedVals, actualValStr) + } + } + } else { + for name, expectedVal := range expected.Response.Headers { + actualVal, ok := cRes.Headers[strings.ToLower(name)] + if !ok { + return fmt.Errorf("expected %s header to be set, actual headers: %v", name, cRes.Headers) + } + actualValStr := strings.Join(actualVal, ",") + if expected.Response.IgnoreWhitespace { + actualValStr = strings.ReplaceAll(actualValStr, " ", "") + expectedVal = strings.ReplaceAll(expectedVal, " ", "") + } + if actualValStr != expectedVal { + return fmt.Errorf("expected %s header to be set to %s, got %s", name, expectedVal, actualValStr) + } } } }