diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 5cbe448d140..c4e6fe32d0b 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -279,6 +279,8 @@ type HTTPRoute struct { Timeout *metav1.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` // load balancer policy to use when routing to the backend endpoints. LoadBalancer *LoadBalancer `json:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"` + // Cors policy for the route. + Cors *Cors `json:"cors,omitempty" yaml:"cors,omitempty"` // ExtensionRefs holds unstructured resources that were introduced by an extension and used on the HTTPRoute as extensionRef filters ExtensionRefs []*UnstructuredRef `json:"extensionRefs,omitempty" yaml:"extensionRefs,omitempty"` } @@ -312,6 +314,22 @@ type JwtRequestAuthentication struct { Providers []egv1a1.JwtAuthenticationFilterProvider `json:"providers,omitempty" yaml:"providers,omitempty"` } +// Cors holds the Cross-Origin Resource Sharing (CORS) policy for the route. +// +// +k8s:deepcopy-gen=true +type Cors struct { + // AllowOrigins defines the origins that are allowed to make requests. + AllowOrigins []*StringMatch `json:"allowOrigins,omitempty" yaml:"allowOrigins,omitempty"` + // AllowMethods defines the methods that are allowed to make requests. + AllowMethods []string `json:"allowMethods,omitempty" yaml:"allowMethods,omitempty"` + // AllowHeaders defines the headers that are allowed to be sent with requests. + AllowHeaders []string `json:"allowHeaders,omitempty" yaml:"allowHeaders,omitempty"` + // ExposeHeaders defines the headers that can be exposed in the responses. + ExposeHeaders []string `json:"exposeHeaders,omitempty" yaml:"exposeHeaders,omitempty"` + // MaxAge defines how long the results of a preflight request can be cached. + MaxAge *metav1.Duration `json:"maxAge,omitempty" yaml:"maxAge,omitempty"` +} + // Validate the fields within the HTTPRoute structure func (h HTTPRoute) Validate() error { var errs error diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 6d8132578b1..ef56202cafc 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -97,6 +97,52 @@ func (in *ConsistentHash) DeepCopy() *ConsistentHash { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Cors) DeepCopyInto(out *Cors) { + *out = *in + if in.AllowOrigins != nil { + in, out := &in.AllowOrigins, &out.AllowOrigins + *out = make([]*StringMatch, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(StringMatch) + (*in).DeepCopyInto(*out) + } + } + } + if in.AllowMethods != nil { + in, out := &in.AllowMethods, &out.AllowMethods + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowHeaders != nil { + in, out := &in.AllowHeaders, &out.AllowHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExposeHeaders != nil { + in, out := &in.ExposeHeaders, &out.ExposeHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.MaxAge != nil { + in, out := &in.MaxAge, &out.MaxAge + *out = new(v1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Cors. +func (in *Cors) DeepCopy() *Cors { + if in == nil { + return nil + } + out := new(Cors) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DestinationEndpoint) DeepCopyInto(out *DestinationEndpoint) { *out = *in @@ -410,6 +456,11 @@ func (in *HTTPRoute) DeepCopyInto(out *HTTPRoute) { *out = new(LoadBalancer) (*in).DeepCopyInto(*out) } + if in.Cors != nil { + in, out := &in.Cors, &out.Cors + *out = new(Cors) + (*in).DeepCopyInto(*out) + } if in.ExtensionRefs != nil { in, out := &in.ExtensionRefs, &out.ExtensionRefs *out = make([]*UnstructuredRef, len(*in)) diff --git a/internal/xds/translator/cors.go b/internal/xds/translator/cors.go new file mode 100644 index 00000000000..775eeeb9ed5 --- /dev/null +++ b/internal/xds/translator/cors.go @@ -0,0 +1,152 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "errors" + "fmt" + "strconv" + "strings" + + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + corsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cors/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + "github.com/envoyproxy/go-control-plane/pkg/wellknown" + "github.com/golang/protobuf/ptypes/wrappers" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/envoyproxy/gateway/internal/ir" +) + +// patchHCMWithCorsFilter builds and appends the Cors Filter to the HTTP +// Connection Manager if applicable. +func patchHCMWithCorsFilter(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { + if mgr == nil { + return errors.New("hcm is nil") + } + + if irListener == nil { + return errors.New("ir listener is nil") + } + + if !listenerContainsCors(irListener) { + return nil + } + + // Return early if filter already exists. + for _, httpFilter := range mgr.HttpFilters { + if httpFilter.Name == wellknown.CORS { + return nil + } + } + + corsFilter, err := buildHCMCorsFilter() + if err != nil { + return err + } + + // Ensure the cors filter is the first one in the filter chain. + mgr.HttpFilters = append([]*hcmv3.HttpFilter{corsFilter}, mgr.HttpFilters...) + + return nil +} + +// buildHCMCorsFilter returns a Cors filter from the provided IR listener. +func buildHCMCorsFilter() (*hcmv3.HttpFilter, error) { + corsProto := &corsv3.Cors{} + + corsAny, err := anypb.New(corsProto) + if err != nil { + return nil, err + } + + return &hcmv3.HttpFilter{ + Name: wellknown.CORS, + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: corsAny, + }, + }, nil +} + +// listenerContainsCors returns true if the provided listener has Cors +// policies attached to its routes. +func listenerContainsCors(irListener *ir.HTTPListener) bool { + if irListener == nil { + return false + } + + for _, route := range irListener.Routes { + if route.Cors != nil { + return true + } + } + + return false +} + +// patchRouteWithCorsConfig patches the provided route with the Cors config if +// applicable. +func patchRouteWithCorsConfig(route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if irRoute.Cors == nil { + return nil + } + + filterCfg := route.GetTypedPerFilterConfig() + if _, ok := filterCfg[wellknown.CORS]; ok { + // This should not happen since this is the only place where the cors + // filter is added in a route. + return fmt.Errorf("route already contains cors config: %+v", route) + } + + var ( + allowOrigins []*matcherv3.StringMatcher + allowMethods string + allowHeaders string + exposeHeaders string + maxAge string + allowCredentials *wrappers.BoolValue + ) + + //nolint:gocritic + + for _, origin := range irRoute.Cors.AllowOrigins { + allowOrigins = append(allowOrigins, buildXdsStringMatcher(origin)) + } + + allowMethods = strings.Join(irRoute.Cors.AllowMethods, ", ") + allowHeaders = strings.Join(irRoute.Cors.AllowHeaders, ", ") + exposeHeaders = strings.Join(irRoute.Cors.ExposeHeaders, ", ") + maxAge = strconv.Itoa(int(irRoute.Cors.MaxAge.Seconds())) + + routeCfgProto := &corsv3.CorsPolicy{ + AllowOriginStringMatch: allowOrigins, + AllowMethods: allowMethods, + AllowHeaders: allowHeaders, + ExposeHeaders: exposeHeaders, + MaxAge: maxAge, + AllowCredentials: allowCredentials, + } + + routeCfgAny, err := anypb.New(routeCfgProto) + if err != nil { + return err + } + + if filterCfg == nil { + route.TypedPerFilterConfig = make(map[string]*anypb.Any) + } + + route.TypedPerFilterConfig[wellknown.CORS] = routeCfgAny + + return nil +} diff --git a/internal/xds/translator/httpfilters.go b/internal/xds/translator/httpfilters.go new file mode 100644 index 00000000000..da5c5aca045 --- /dev/null +++ b/internal/xds/translator/httpfilters.go @@ -0,0 +1,142 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "sort" + + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + "github.com/envoyproxy/go-control-plane/pkg/wellknown" + + "github.com/envoyproxy/gateway/internal/ir" + xdsfilters "github.com/envoyproxy/gateway/internal/xds/filters" +) + +type OrderedHTTPFilter struct { + filter *hcmv3.HttpFilter + order int +} + +type OrderedHTTPFilters []*OrderedHTTPFilter + +// newOrderedHTTPFilter gives each HTTP filter a rational order. +// This is needed because the order of the filters is important. +// For example, the cors filter should be put at the first to avoid unnecessary +// processing of other filters for unauthorized cross-region access. +// The router filter must be the last one since it's a terminal filter. +// +// Important: please modify this method and set the order for the new filter +// when adding a new filter in the HCM filter chain. +// If the order is not explicitly specified in this method, a filter will be set +// a default order 50. +func newOrderedHTTPFilter(filter *hcmv3.HttpFilter) *OrderedHTTPFilter { + order := 50 + + // Set a rational order for all the filters. + switch filter.Name { + case wellknown.CORS: + order = 1 + case jwtAuthenFilter: + order = 2 + case wellknown.HTTPRateLimit: + order = 3 + case wellknown.Router: + order = 100 + } + + return &OrderedHTTPFilter{ + filter: filter, + order: order, + } +} + +// sort.Interface implementation. +func (o OrderedHTTPFilters) Len() int { + return len(o) +} + +func (o OrderedHTTPFilters) Less(i, j int) bool { + return o[i].order < o[j].order +} + +func (o OrderedHTTPFilters) Swap(i, j int) { + o[i], o[j] = o[j], o[i] +} + +// sortHTTPFilters sorts the HTTP filters in the correct order. +// This is needed because the order of the filters is important. +// For example, the cors filter should be put at the first to avoid unnecessary +// processing of other filters for unauthorized cross-region access. +// The router filter must be the last one since it's a terminal filter. +func sortHTTPFilters(filters []*hcmv3.HttpFilter) []*hcmv3.HttpFilter { + orderedFilters := make(OrderedHTTPFilters, len(filters)) + for i := 0; i < len(filters); i++ { + orderedFilters[i] = newOrderedHTTPFilter(filters[i]) + } + sort.Sort(orderedFilters) + + for i := 0; i < len(filters); i++ { + filters[i] = orderedFilters[i].filter + } + return filters +} + +// patchHCMWithFilters builds and appends HTTP Filters to the HTTP connection +// manager. +// Important: don't forget to set the order for newly added filters in the +// newOrderedHTTPFilter method. +func (t *Translator) patchHCMWithFilters( + mgr *hcmv3.HttpConnectionManager, + irListener *ir.HTTPListener) error { + // The order of filter patching is not relevant here. + // All the filters will be sorted in correct order after the patching is done. + // Important: don't forget to set the order for new filters in the + // newOrderedHTTPFilter method. + // TODO: Make this a generic interface for all API Gateway features. + // https://github.com/envoyproxy/gateway/issues/882 + t.patchHCMWithRateLimit(mgr, irListener) + + // Add the jwt authn filter, if needed. + if err := patchHCMWithJwtAuthnFilter(mgr, irListener); err != nil { + return err + } + + // Add the cors filter, if needed + if err := patchHCMWithCorsFilter(mgr, irListener); err != nil { + return err + } + + // Add the router filter + mgr.HttpFilters = append(mgr.HttpFilters, xdsfilters.HTTPRouter) + + // Sort the filters in the correct order. + mgr.HttpFilters = sortHTTPFilters(mgr.HttpFilters) + return nil +} + +// patchRouteWithFilters appends per-route filter configurations to the route. +func patchRouteWithFilters( + route *routev3.Route, + irRoute *ir.HTTPRoute) error { + // TODO: Convert this into a generic interface for API Gateway features. + // https://github.com/envoyproxy/gateway/issues/882 + if err := + patchRouteWithRateLimit(route.GetRoute(), irRoute); err != nil { + return nil + } + + // Add the jwt per route config to the route, if needed. + if err := patchRouteWithJwtConfig(route, irRoute); err != nil { + return nil + } + + // Add the cors per route config to the route, if needed. + if err := patchRouteWithCorsConfig(route, irRoute); err != nil { + return err + } + return nil +} diff --git a/internal/xds/translator/httpfilters_test.go b/internal/xds/translator/httpfilters_test.go new file mode 100644 index 00000000000..928afb183c4 --- /dev/null +++ b/internal/xds/translator/httpfilters_test.go @@ -0,0 +1,49 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "testing" + + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + "github.com/envoyproxy/go-control-plane/pkg/wellknown" + "github.com/stretchr/testify/assert" +) + +func Test_sortHTTPFilters(t *testing.T) { + tests := []struct { + name string + filters []*hcmv3.HttpFilter + want []*hcmv3.HttpFilter + }{ + { + name: "sort filters", + filters: []*hcmv3.HttpFilter{ + httpFilterForTest(wellknown.Router), + httpFilterForTest(wellknown.CORS), + httpFilterForTest(jwtAuthenFilter), + httpFilterForTest(wellknown.HTTPRateLimit), + }, + want: []*hcmv3.HttpFilter{ + httpFilterForTest(wellknown.CORS), + httpFilterForTest(jwtAuthenFilter), + httpFilterForTest(wellknown.HTTPRateLimit), + httpFilterForTest(wellknown.Router), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, sortHTTPFilters(tt.filters), "sortHTTPFilters(%v)", tt.filters) + }) + } +} + +func httpFilterForTest(name string) *hcmv3.HttpFilter { + return &hcmv3.HttpFilter{ + Name: name, + } +} diff --git a/internal/xds/translator/listener.go b/internal/xds/translator/listener.go index 5c5d9a80d5a..1a118f56426 100644 --- a/internal/xds/translator/listener.go +++ b/internal/xds/translator/listener.go @@ -134,17 +134,12 @@ func (t *Translator) addXdsHTTPFilterChain(xdsListener *listenerv3.Listener, irL } } - // TODO: Make this a generic interface for all API Gateway features. - // https://github.com/envoyproxy/gateway/issues/882 - t.patchHCMWithRateLimit(mgr, irListener) - - // Add the jwt authn filter, if needed. - if err := patchHCMWithJwtAuthnFilter(mgr, irListener); err != nil { + // Add HTTP filters to the HCM, the filters have already been sorted in the + // correct order in the patchHCMWithFilters function. + if err := t.patchHCMWithFilters(mgr, irListener); err != nil { return err } - // Make sure the router filter is the last one. - mgr.HttpFilters = append(mgr.HttpFilters, xdsfilters.HTTPRouter) mgrAny, err := protocov.ToAnyWithError(mgr) if err != nil { return err diff --git a/internal/xds/translator/route.go b/internal/xds/translator/route.go index 6b273b25027..e9546e22772 100644 --- a/internal/xds/translator/route.go +++ b/internal/xds/translator/route.go @@ -76,14 +76,8 @@ func buildXdsRoute(httpRoute *ir.HTTPRoute) *routev3.Route { router.GetRoute().Timeout = durationpb.New(httpRoute.Timeout.Duration) } - // TODO: Convert this into a generic interface for API Gateway features. - // https://github.com/envoyproxy/gateway/issues/882 - if err := patchRouteWithRateLimit(router.GetRoute(), httpRoute); err != nil { - return nil - } - - // Add the jwt per route config to the route, if needed. - if err := patchRouteWithJwtConfig(router, httpRoute); err != nil { + // Add per route filter configs to the route, if needed. + if err := patchRouteWithFilters(router, httpRoute); err != nil { return nil } diff --git a/internal/xds/translator/testdata/in/xds-ir/cors.yaml b/internal/xds/translator/testdata/in/xds-ir/cors.yaml new file mode 100644 index 00000000000..d7dc9f71f7a --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/cors.yaml @@ -0,0 +1,36 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + routes: + - name: "first-route" + hostname: "*" + pathMatch: + exact: "foo/bar" + destination: + name: "first-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + cors: + allowOrigins: + - name: example.com + stringMatch: + safeRegex: "*.example.com" + - name: foo.bar.com + stringMatch: + exact: foo.bar.com + allowMethods: + - GET + - POST + allowHeaders: + - "x-header-1" + - "x-header-2" + exposeHeaders: + - "x-header-3" + - "x-header-4" + maxAge: 1000s + allowPrivateNetworkAccess: false diff --git a/internal/xds/translator/testdata/out/xds-ir/cors.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/cors.clusters.yaml new file mode 100755 index 00000000000..c8692b81602 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/cors.clusters.yaml @@ -0,0 +1,14 @@ +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: first-route-dest + lbPolicy: LEAST_REQUEST + name: first-route-dest + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/cors.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/cors.endpoints.yaml new file mode 100755 index 00000000000..0d68b430c20 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/cors.endpoints.yaml @@ -0,0 +1,11 @@ +- clusterName: first-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: {} diff --git a/internal/xds/translator/testdata/out/xds-ir/cors.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/cors.listeners.yaml new file mode 100755 index 00000000000..c48a6d76424 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/cors.listeners.yaml @@ -0,0 +1,36 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.cors + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: first-listener + statPrefix: http + upgradeConfigs: + - upgradeType: websocket + useRemoteAddress: true + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/cors.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/cors.routes.yaml new file mode 100755 index 00000000000..681285a8d7e --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/cors.routes.yaml @@ -0,0 +1,23 @@ +- ignorePortInHostMatching: true + name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener/* + routes: + - match: + path: foo/bar + name: first-route + route: + cluster: first-route-dest + typedPerFilterConfig: + envoy.filters.http.cors: + '@type': type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy + allowHeaders: x-header-1, x-header-2 + allowMethods: GET, POST + allowOriginStringMatch: + - safeRegex: + regex: '*.example.com' + - exact: foo.bar.com + exposeHeaders: x-header-3, x-header-4 + maxAge: "1000" diff --git a/internal/xds/translator/translator_test.go b/internal/xds/translator/translator_test.go index 17d774d27b4..2ca793722f1 100644 --- a/internal/xds/translator/translator_test.go +++ b/internal/xds/translator/translator_test.go @@ -190,6 +190,9 @@ func TestTranslateXds(t *testing.T) { { name: "load-balancer", }, + { + name: "cors", + }, } for _, tc := range testCases {