diff --git a/docs/content/reference/routing-configuration/kubernetes/gateway-api.md b/docs/content/reference/routing-configuration/kubernetes/gateway-api.md index dbcc2bdf4e..a1304771fe 100644 --- a/docs/content/reference/routing-configuration/kubernetes/gateway-api.md +++ b/docs/content/reference/routing-configuration/kubernetes/gateway-api.md @@ -320,6 +320,17 @@ There are three types of filters: - **Extended:** Optional filters for Gateway controllers, such as `ResponseHeaderModifier` and `RequestMirror`. - **ExtensionRef:** Additional filters provided by the Gateway controller. In Traefik, these are the [HTTP middlewares](../http/middlewares/overview.md) supported through the [Middleware CRD](../kubernetes/crd/http/middleware.md). +!!! info "Supported Filter Types" + + Traefik supports the following route-level filter types: + + - `RequestHeaderModifier`: Add, set, or remove HTTP request headers before forwarding to the backend. + - `ResponseHeaderModifier`: Add, set, or remove HTTP response headers. + - `RequestRedirect`: Redirect the request to a different URL. + - `URLRewrite`: Rewrite the request URL path and/or hostname. + - `CORS`: Configure Cross-Origin Resource Sharing (CORS) response headers. + - `ExtensionRef`: Reference a Traefik [Middleware](../kubernetes/crd/http/middleware.md) resource. + !!! info "ExtensionRef Filters" To use Traefik middlewares as `ExtensionRef` filters, the Kubernetes CRD provider must be enabled in the static configuration, as detailed in the [documentation](../../install-configuration/providers/kubernetes/kubernetes-crd.md). @@ -442,6 +453,7 @@ This allows request modifications to be applied to specific backends, enabling t - `ResponseHeaderModifier`: Add, set, or remove HTTP response headers. - `RequestRedirect`: Redirect the request to a different URL. - `URLRewrite`: Rewrite the request URL path and/or hostname. + - `CORS`: Configure Cross-Origin Resource Sharing (CORS) response headers. - `ExtensionRef`: Reference a Traefik [Middleware](../kubernetes/crd/http/middleware.md) resource. !!! info "Middlewares Execution Order" diff --git a/integration/gateway-api-conformance-reports/v1.5.1/experimental-v3.7-default-report.yaml b/integration/gateway-api-conformance-reports/v1.5.1/experimental-v3.7-default-report.yaml index 29294f87b7..ceecdf3807 100644 --- a/integration/gateway-api-conformance-reports/v1.5.1/experimental-v3.7-default-report.yaml +++ b/integration/gateway-api-conformance-reports/v1.5.1/experimental-v3.7-default-report.yaml @@ -30,7 +30,7 @@ profiles: result: success statistics: Failed: 0 - Passed: 27 + Passed: 29 Skipped: 0 supportedFeatures: - BackendTLSPolicy @@ -42,6 +42,7 @@ profiles: - HTTPRouteBackendProtocolH2C - HTTPRouteBackendProtocolWebSocket - HTTPRouteBackendRequestHeaderModification + - HTTPRouteCORS - HTTPRouteDestinationPortMatching - HTTPRouteHostRewrite - HTTPRouteMethodMatching @@ -63,7 +64,6 @@ profiles: - GatewayInfrastructurePropagation - GatewayStaticAddresses - HTTPRouteBackendTimeout - - HTTPRouteCORS - HTTPRouteRequestMirror - HTTPRouteRequestMultipleMirrors - HTTPRouteRequestPercentageMirror diff --git a/pkg/provider/kubernetes/gateway/features.go b/pkg/provider/kubernetes/gateway/features.go index ad936aa13e..6363510623 100644 --- a/pkg/provider/kubernetes/gateway/features.go +++ b/pkg/provider/kubernetes/gateway/features.go @@ -61,6 +61,7 @@ func extendedHTTPRouteFeatures() sets.Set[features.Feature] { features.HTTPRouteBackendRequestHeaderModificationFeature, features.HTTPRouteNamedRouteRule, features.HTTPRouteParentRefPortFeature, + features.HTTPRouteCORS, ) } diff --git a/pkg/provider/kubernetes/gateway/httproute.go b/pkg/provider/kubernetes/gateway/httproute.go index 7d0b165fbf..f38f74c66d 100644 --- a/pkg/provider/kubernetes/gateway/httproute.go +++ b/pkg/provider/kubernetes/gateway/httproute.go @@ -407,6 +407,12 @@ func (p *Provider) loadMiddlewares(conf *dynamic.Configuration, namespace, paren middleware, }) + case gatev1.HTTPRouteFilterCORS: + middlewares = append(middlewares, namedMiddleware{ + name, + createCORS(filter.CORS), + }) + default: // As per the spec: https://gateway-api.sigs.k8s.io/api-types/httproute/#filters-optional // In all cases where incompatible or unsupported filters are @@ -962,6 +968,52 @@ func createURLRewrite(filter *gatev1.HTTPURLRewriteFilter, pathMatch gatev1.HTTP }, nil } +func createCORS(filter *gatev1.HTTPCORSFilter) *dynamic.Middleware { + var allowOrigins, allowOriginsRegex []string + for _, origin := range filter.AllowOrigins { + if prefix, suffix, found := strings.Cut(string(origin), "*"); found { + switch { + case prefix != "" || suffix != "": + allowOriginsRegex = append(allowOriginsRegex, "^"+regexp.QuoteMeta(prefix)+`.*`+regexp.QuoteMeta(suffix)+"$") + default: + // Convert to regex so the middleware echoes the specific request origin rather than the literal "*". + // The Gateway API conformance tests require the specific origin, even when AllowCredentials is false/unset. + allowOriginsRegex = append(allowOriginsRegex, ".*") + } + continue + } + + allowOrigins = append(allowOrigins, string(origin)) + } + + var allowMethods []string + for _, m := range filter.AllowMethods { + allowMethods = append(allowMethods, string(m)) + } + + var allowHeaders []string + for _, h := range filter.AllowHeaders { + allowHeaders = append(allowHeaders, string(h)) + } + + var exposeHeaders []string + for _, h := range filter.ExposeHeaders { + exposeHeaders = append(exposeHeaders, string(h)) + } + + return &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowCredentials: ptr.Deref(filter.AllowCredentials, false), + AccessControlAllowOriginList: allowOrigins, + AccessControlAllowOriginListRegex: allowOriginsRegex, + AccessControlAllowMethods: allowMethods, + AccessControlAllowHeaders: allowHeaders, + AccessControlExposeHeaders: exposeHeaders, + AccessControlMaxAge: ptr.To(int64(filter.MaxAge)), + }, + } +} + func getHTTPServiceProtocol(portSpec corev1.ServicePort) (string, error) { if portSpec.Protocol != corev1.ProtocolTCP { return "", errors.New("only TCP protocol is supported") diff --git a/pkg/provider/kubernetes/gateway/httproute_test.go b/pkg/provider/kubernetes/gateway/httproute_test.go index fa39e38b6f..1bf719cdc5 100644 --- a/pkg/provider/kubernetes/gateway/httproute_test.go +++ b/pkg/provider/kubernetes/gateway/httproute_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/traefik/traefik/v3/pkg/config/dynamic" "k8s.io/utils/ptr" gatev1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -203,3 +204,123 @@ func Test_buildMatchRule(t *testing.T) { }) } } + +func Test_createCORS(t *testing.T) { + testCases := []struct { + desc string + filter *gatev1.HTTPCORSFilter + expected *dynamic.Middleware + }{ + { + desc: "Empty filter", + filter: &gatev1.HTTPCORSFilter{}, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + { + desc: "Plain origins", + filter: &gatev1.HTTPCORSFilter{ + AllowOrigins: []gatev1.CORSOrigin{"https://foo.example.com", "https://bar.example.com"}, + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowOriginList: []string{"https://foo.example.com", "https://bar.example.com"}, + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + { + desc: "Wildcard origin becomes catch-all regex", + filter: &gatev1.HTTPCORSFilter{ + AllowOrigins: []gatev1.CORSOrigin{"*"}, + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowOriginListRegex: []string{`.*`}, + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + { + desc: "Origin with bare wildcard host", + filter: &gatev1.HTTPCORSFilter{ + AllowOrigins: []gatev1.CORSOrigin{"https://*"}, + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowOriginListRegex: []string{`^https://.*$`}, + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + { + desc: "Origin with wildcard subdomain becomes regex", + filter: &gatev1.HTTPCORSFilter{ + AllowOrigins: []gatev1.CORSOrigin{"https://*.example.com"}, + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowOriginListRegex: []string{`^https://.*\.example\.com$`}, + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + { + desc: "Mixed plain and wildcard origins", + filter: &gatev1.HTTPCORSFilter{ + AllowOrigins: []gatev1.CORSOrigin{"https://foo.example.com", "https://*.example.com"}, + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowOriginList: []string{"https://foo.example.com"}, + AccessControlAllowOriginListRegex: []string{`^https://.*\.example\.com$`}, + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + { + desc: "All fields set", + filter: &gatev1.HTTPCORSFilter{ + AllowOrigins: []gatev1.CORSOrigin{"https://foo.example.com"}, + AllowCredentials: ptr.To(true), + AllowMethods: []gatev1.HTTPMethodWithWildcard{"GET", "POST"}, + AllowHeaders: []gatev1.HTTPHeaderName{"X-Foo", "X-Bar"}, + ExposeHeaders: []gatev1.HTTPHeaderName{"X-Baz"}, + MaxAge: 600, + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowCredentials: true, + AccessControlAllowOriginList: []string{"https://foo.example.com"}, + AccessControlAllowMethods: []string{"GET", "POST"}, + AccessControlAllowHeaders: []string{"X-Foo", "X-Bar"}, + AccessControlExposeHeaders: []string{"X-Baz"}, + AccessControlMaxAge: ptr.To(int64(600)), + }, + }, + }, + { + desc: "AllowCredentials explicitly false", + filter: &gatev1.HTTPCORSFilter{ + AllowCredentials: ptr.To(false), + }, + expected: &dynamic.Middleware{ + Headers: &dynamic.Headers{ + AccessControlAllowCredentials: false, + AccessControlMaxAge: ptr.To(int64(0)), + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, createCORS(test.filter)) + }) + } +}