diff --git a/api/v1alpha1/httproutefilter_types.go b/api/v1alpha1/httproutefilter_types.go index 9415db0b83..9d8c5df305 100644 --- a/api/v1alpha1/httproutefilter_types.go +++ b/api/v1alpha1/httproutefilter_types.go @@ -127,6 +127,10 @@ const ( // BackendHTTPHostnameModifier indicates that the Host header value would be replaced by the DNS name of the backend if it exists. // https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-auto-host-rewrite BackendHTTPHostnameModifier HTTPHostnameModifierType = "Backend" + // PathRegexHTTPHostnameModifier indicates that the Host header value would be rewritten by applying a regex + // match and substitution to the request path. + // https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-host-rewrite-path-regex + PathRegexHTTPHostnameModifier HTTPHostnameModifierType = "PathRegex" ) type ReplaceRegexMatch struct { @@ -139,6 +143,26 @@ type ReplaceRegexMatch struct { Substitution string `json:"substitution"` } +// HostnamePathRegexRewrite defines a hostname rewrite computed from the request path using regex. +type HostnamePathRegexRewrite struct { + // Pattern matches a regular expression against the value of the HTTP Path. The regex string must + // adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + // +kubebuilder:validation:MinLength=1 + Pattern string `json:"pattern"` + // Substitution is an expression that replaces the matched portion. The expression may include numbered + // capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + // + // The resulting value is used as the upstream Host header and should be constrained to a valid + // DNS hostname by using explicit regex capture groups in Pattern. + // + // The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are + // rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the + // generated configuration to be rejected by the data plane. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[^\r\n\x00]*$` + Substitution string `json:"substitution"` +} + // +kubebuilder:validation:XValidation:rule="self.type == 'ReplaceRegexMatch' ? has(self.replaceRegexMatch) : !has(self.replaceRegexMatch)",message="If HTTPPathModifier type is ReplaceRegexMatch, replaceRegexMatch field needs to be set." type HTTPPathModifier struct { // +kubebuilder:validation:Enum=ReplaceRegexMatch @@ -169,14 +193,28 @@ type HTTPPathModifier struct { // +kubebuilder:validation:XValidation:message="header must be nil if the type is not Header",rule="!(has(self.header) && self.type != 'Header')" // +kubebuilder:validation:XValidation:message="header must be specified for Header type",rule="!(!has(self.header) && self.type == 'Header')" +// +kubebuilder:validation:XValidation:message="pathRegex must be nil if the type is not PathRegex",rule="!(has(self.pathRegex) && self.type != 'PathRegex')" +// +kubebuilder:validation:XValidation:message="pathRegex must be specified for PathRegex type",rule="!(!has(self.pathRegex) && self.type == 'PathRegex')" type HTTPHostnameModifier struct { - // +kubebuilder:validation:Enum=Header;Backend + // +kubebuilder:validation:Enum=Header;Backend;PathRegex // +kubebuilder:validation:Required Type HTTPHostnameModifierType `json:"type"` // Header is the name of the header whose value would be used to rewrite the Host header // +optional Header *string `json:"header,omitempty"` + + // PathRegex defines a regex match and substitution applied to the request path to compute + // the rewritten Host header. + // For example, with: + // pathRegex: + // pattern: "^/tenant/([a-z0-9-]+)/.*" + // substitution: "\\1.example.internal" + // a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten + // to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). + // + // +optional + PathRegex *HostnamePathRegexRewrite `json:"pathRegex,omitempty"` } // HTTPCredentialInjectionFilter defines the configuration to inject credentials into the request. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 7245a49145..86fe832d34 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -4532,6 +4532,11 @@ func (in *HTTPHostnameModifier) DeepCopyInto(out *HTTPHostnameModifier) { *out = new(string) **out = **in } + if in.PathRegex != nil { + in, out := &in.PathRegex, &out.PathRegex + *out = new(HostnamePathRegexRewrite) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPHostnameModifier. @@ -4981,6 +4986,21 @@ func (in *HostSettings) DeepCopy() *HostSettings { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostnamePathRegexRewrite) DeepCopyInto(out *HostnamePathRegexRewrite) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostnamePathRegexRewrite. +func (in *HostnamePathRegexRewrite) DeepCopy() *HostnamePathRegexRewrite { + if in == nil { + return nil + } + out := new(HostnamePathRegexRewrite) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IPEndpoint) DeepCopyInto(out *IPEndpoint) { *out = *in diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_httproutefilters.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_httproutefilters.yaml index 4d0177ab7e..57e3053b85 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_httproutefilters.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_httproutefilters.yaml @@ -424,12 +424,48 @@ spec: description: Header is the name of the header whose value would be used to rewrite the Host header type: string + pathRegex: + description: |- + PathRegex defines a regex match and substitution applied to the request path to compute + the rewritten Host header. + For example, with: + pathRegex: + pattern: "^/tenant/([a-z0-9-]+)/.*" + substitution: "\\1.example.internal" + a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten + to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). + properties: + pattern: + description: |- + Pattern matches a regular expression against the value of the HTTP Path. The regex string must + adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 + type: string + substitution: + description: |- + Substitution is an expression that replaces the matched portion. The expression may include numbered + capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + + The resulting value is used as the upstream Host header and should be constrained to a valid + DNS hostname by using explicit regex capture groups in Pattern. + + The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are + rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the + generated configuration to be rejected by the data plane. + minLength: 1 + pattern: ^[^\r\n\x00]*$ + type: string + required: + - pattern + - substitution + type: object type: description: HTTPPathModifierType defines the type of Hostname rewrite. enum: - Header - Backend + - PathRegex type: string required: - type @@ -439,6 +475,10 @@ spec: rule: '!(has(self.header) && self.type != ''Header'')' - message: header must be specified for Header type rule: '!(!has(self.header) && self.type == ''Header'')' + - message: pathRegex must be nil if the type is not PathRegex + rule: '!(has(self.pathRegex) && self.type != ''PathRegex'')' + - message: pathRegex must be specified for PathRegex type + rule: '!(!has(self.pathRegex) && self.type == ''PathRegex'')' path: description: Path defines a path rewrite. properties: diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml index b213232c6d..1ca1eb7156 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml @@ -423,12 +423,48 @@ spec: description: Header is the name of the header whose value would be used to rewrite the Host header type: string + pathRegex: + description: |- + PathRegex defines a regex match and substitution applied to the request path to compute + the rewritten Host header. + For example, with: + pathRegex: + pattern: "^/tenant/([a-z0-9-]+)/.*" + substitution: "\\1.example.internal" + a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten + to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). + properties: + pattern: + description: |- + Pattern matches a regular expression against the value of the HTTP Path. The regex string must + adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 + type: string + substitution: + description: |- + Substitution is an expression that replaces the matched portion. The expression may include numbered + capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + + The resulting value is used as the upstream Host header and should be constrained to a valid + DNS hostname by using explicit regex capture groups in Pattern. + + The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are + rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the + generated configuration to be rejected by the data plane. + minLength: 1 + pattern: ^[^\r\n\x00]*$ + type: string + required: + - pattern + - substitution + type: object type: description: HTTPPathModifierType defines the type of Hostname rewrite. enum: - Header - Backend + - PathRegex type: string required: - type @@ -438,6 +474,10 @@ spec: rule: '!(has(self.header) && self.type != ''Header'')' - message: header must be specified for Header type rule: '!(!has(self.header) && self.type == ''Header'')' + - message: pathRegex must be nil if the type is not PathRegex + rule: '!(has(self.pathRegex) && self.type != ''PathRegex'')' + - message: pathRegex must be specified for PathRegex type + rule: '!(!has(self.pathRegex) && self.type == ''PathRegex'')' path: description: Path defines a path rewrite. properties: diff --git a/internal/gatewayapi/filters.go b/internal/gatewayapi/filters.go index f3cf05633b..972a6fe6ff 100644 --- a/internal/gatewayapi/filters.go +++ b/internal/gatewayapi/filters.go @@ -250,7 +250,8 @@ func hasMultipleCoreRewrites(rewrite *gwapiv1.HTTPURLRewriteFilter, contextRewri // Checks if the context and the rewrite both contain a envoy-gateway extended HTTP URL rewrite func hasMultipleExtensionRewrites(rewrite *egv1a1.HTTPURLRewriteFilter, contextRewrite *ir.URLRewrite) bool { contextHasExtensionRewrites := (contextRewrite.Path != nil && contextRewrite.Path.RegexMatchReplace != nil) || - (contextRewrite.Host != nil && (contextRewrite.Host.Header != nil || contextRewrite.Host.Backend != nil)) + (contextRewrite.Host != nil && (contextRewrite.Host.Header != nil || contextRewrite.Host.Backend != nil || + contextRewrite.Host.PathRegex != nil)) return contextHasExtensionRewrites && (rewrite.Hostname != nil || rewrite.Path != nil) } @@ -259,7 +260,7 @@ func hasMultipleExtensionRewrites(rewrite *egv1a1.HTTPURLRewriteFilter, contextR func hasConflictingCoreAndExtensionRewrites(rewrite *gwapiv1.HTTPURLRewriteFilter, contextRewrite *ir.URLRewrite) bool { contextHasExtensionPathRewrites := contextRewrite.Path != nil && contextRewrite.Path.RegexMatchReplace != nil contextHasExtensionHostRewrites := contextRewrite.Host != nil && (contextRewrite.Host.Header != nil || - contextRewrite.Host.Backend != nil) + contextRewrite.Host.Backend != nil || contextRewrite.Host.PathRegex != nil) return (rewrite.Hostname != nil && contextHasExtensionHostRewrites) || (rewrite.Path != nil && contextHasExtensionPathRewrites) } @@ -902,6 +903,28 @@ func (t *Translator) processExtensionRefHTTPFilter(extFilter *gwapiv1.LocalObjec hm = &ir.HTTPHostModifier{ Backend: new(true), } + case egv1a1.PathRegexHTTPHostnameModifier: + if hrf.Spec.URLRewrite.Hostname.PathRegex == nil || + hrf.Spec.URLRewrite.Hostname.PathRegex.Pattern == "" || + hrf.Spec.URLRewrite.Hostname.PathRegex.Substitution == "" { + return status.NewRouteStatusError( + errors.New("PathRegex Pattern and Substitution must be set when rewrite hostname type is \"PathRegex\""), + gwapiv1.RouteReasonUnsupportedValue, + ).WithType(gwapiv1.RouteConditionAccepted) + } else if _, err := regexp.Compile(hrf.Spec.URLRewrite.Hostname.PathRegex.Pattern); err != nil { + // Avoid envoy NACKs due to invalid regex. + // Go's regexp syntax is RE2: https://pkg.go.dev/regexp/syntax + return status.NewRouteStatusError( + errors.New("PathRegex must be a valid RE2 regular expression"), + gwapiv1.RouteReasonUnsupportedValue, + ).WithType(gwapiv1.RouteConditionAccepted) + } + hm = &ir.HTTPHostModifier{ + PathRegex: &ir.RegexMatchReplace{ + Pattern: hrf.Spec.URLRewrite.Hostname.PathRegex.Pattern, + Substitution: hrf.Spec.URLRewrite.Hostname.PathRegex.Substitution, + }, + } } if filterContext.URLRewrite != nil { diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 18d6db8363..aa4d39387f 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -392,6 +392,33 @@ func (t *Translator) processHTTPRouteRules(httpRoute *HTTPRouteContext, parentRe t.Logger.Info("setting 500 direct response in routes due to all valid destinations having 0 weight", "routes", sets.List(routesWithDirectResponse)) } + // Host rewrite from path (PathRegex) is rejected for dynamic resolver routes: the upstream host is + // derived from request-controlled path text, which is not validated by the dynamic forward proxy + // loopback protection (that guard only inspects the rewrite header or :authority). Allowing it would + // let a crafted path resolve to a loopback address and bypass the SSRF protection. + case hasDynamicResolver && hasPathRegexHostRewrite(ruleRoutes): + routesWithDirectResponse := sets.New[string]() + for _, irRoute := range ruleRoutes { + // If the route already has a direct response or redirect configured, then it was from a filter so skip + // the direct response from errors. + if irRoute.DirectResponse != nil || irRoute.Redirect != nil { + continue + } + irRoute.DirectResponse = &ir.CustomResponse{ + StatusCode: new(uint32(500)), + } + routesWithDirectResponse.Insert(irRoute.Name) + } + errorCollector.Add(status.NewRouteStatusError( + fmt.Errorf( + "failed to process route rule %d: host rewrite from path (PathRegex) is not supported with a dynamic resolver backend", + ruleIdx), + gwapiv1.RouteReasonUnsupportedValue, + )) + if len(routesWithDirectResponse) > 0 { + t.Logger.Info("setting 500 direct response in routes due to dynamic resolver with host rewrite from path", + "routes", sets.List(routesWithDirectResponse)) + } // A route can only have one destination if this destination is a dynamic resolver, because the behavior of // multiple destinations with one being a dynamic resolver just doesn't make sense. case hasDynamicResolver && len(rule.BackendRefs) > 1: @@ -455,6 +482,17 @@ func (t *Translator) processHTTPRouteRules(httpRoute *HTTPRouteContext, parentRe return irRoutes, errorCollector.GetAllErrors(), unacceptedRules.List() } +// hasPathRegexHostRewrite reports whether any of the given IR routes rewrites the upstream host +// from the request path via a regex substitution (urlRewrite.hostname.type: PathRegex). +func hasPathRegexHostRewrite(routes []*ir.HTTPRoute) bool { + for _, irRoute := range routes { + if irRoute.URLRewrite != nil && irRoute.URLRewrite.Host != nil && irRoute.URLRewrite.Host.PathRegex != nil { + return true + } + } + return false +} + type routeMatchCombination struct { gwapiv1.HTTPRouteMatch cookies []egv1a1.HTTPCookieMatch diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.in.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.in.yaml index 5a7f449904..12fb8895ee 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.in.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.in.yaml @@ -186,6 +186,122 @@ httpRoutes: group: gateway.envoyproxy.io kind: HTTPRouteFilter name: valid-header + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-header-and-pathregex-host-rewrites + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/header-and-pathregex" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-header + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-path-regex + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-invalid-path-regex + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/invalid-path-regex" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: invalid-path-regex + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-missing-path-regex + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/missing-path-regex" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: missing-path-regex + # PathRegex host rewrite is invalid on a dynamic resolver backend: the upstream host would be + # derived from request-controlled path text, bypassing the dynamic forward proxy loopback protection. + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-dynamic-resolver-path-regex + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/dynamic-resolver-path-regex" + backendRefs: + - group: gateway.envoyproxy.io + kind: Backend + name: backend-dynamic-resolver + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-path-regex +backends: + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: Backend + metadata: + name: backend-dynamic-resolver + namespace: default + spec: + type: DynamicResolver httpFilters: - apiVersion: gateway.envoyproxy.io/v1alpha1 kind: HTTPRouteFilter @@ -234,3 +350,37 @@ httpFilters: urlRewrite: hostname: type: Header + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: valid-path-regex + namespace: default + spec: + urlRewrite: + hostname: + type: PathRegex + pathRegex: + pattern: "^/[^/]+/([^/]+).*" + substitution: "\\1" + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: invalid-path-regex + namespace: default + spec: + urlRewrite: + hostname: + type: PathRegex + pathRegex: + pattern: '"([a-z]+)"*+?' + substitution: "backend-\\1.service.namespace.svc.cluster.local" + header: my-host + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: missing-path-regex + namespace: default + spec: + urlRewrite: + hostname: + type: PathRegex diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.out.yaml index cad290b2e0..417ae07eb4 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter-invalid.out.yaml @@ -1,3 +1,18 @@ +backends: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: Backend + metadata: + name: backend-dynamic-resolver + namespace: default + spec: + type: DynamicResolver + status: + conditions: + - lastTransitionTime: null + message: The Backend was accepted + reason: Accepted + status: "True" + type: Accepted gateways: - apiVersion: gateway.networking.k8s.io/v1 kind: Gateway @@ -16,7 +31,7 @@ gateways: protocol: HTTP status: listeners: - - attachedRoutes: 6 + - attachedRoutes: 10 conditions: - lastTransitionTime: null message: Sending translated listener configuration to the data plane @@ -319,6 +334,186 @@ httpRoutes: name: gateway-1 namespace: envoy-gateway sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-header-and-pathregex-host-rewrites + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-header + type: ExtensionRef + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-path-regex + type: ExtensionRef + matches: + - path: + value: /header-and-pathregex + status: + parents: + - conditions: + - lastTransitionTime: null + message: Cannot configure multiple urlRewrite filters for a single HTTPRouteRule. + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-invalid-path-regex + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: invalid-path-regex + type: ExtensionRef + matches: + - path: + value: /invalid-path-regex + status: + parents: + - conditions: + - lastTransitionTime: null + message: PathRegex must be a valid RE2 regular expression. + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-missing-path-regex + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: missing-path-regex + type: ExtensionRef + matches: + - path: + value: /missing-path-regex + status: + parents: + - conditions: + - lastTransitionTime: null + message: PathRegex Pattern and Substitution must be set when rewrite hostname + type is "PathRegex". + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-dynamic-resolver-path-regex + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - group: gateway.envoyproxy.io + kind: Backend + name: backend-dynamic-resolver + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-path-regex + type: ExtensionRef + matches: + - path: + value: /dynamic-resolver-path-regex + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'Failed to process route rule 0: host rewrite from path (PathRegex) + is not supported with a dynamic resolver backend.' + reason: UnsupportedValue + status: "False" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http infraIR: envoy-gateway/gateway-1: proxy: @@ -380,6 +575,40 @@ xdsIR: mergeSlashes: true port: 10080 routes: + - directResponse: + statusCode: 500 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-dynamic-resolver-path-regex + namespace: default + name: httproute/default/httproute-dynamic-resolver-path-regex/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /dynamic-resolver-path-regex + urlRewrite: + host: + pathRegex: + pattern: ^/[^/]+/([^/]+).* + substitution: \1 + - directResponse: + statusCode: 500 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-header-and-pathregex-host-rewrites + namespace: default + name: httproute/default/httproute-header-and-pathregex-host-rewrites/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /header-and-pathregex + urlRewrite: + host: + header: my-host - directResponse: statusCode: 500 hostname: gateway.envoyproxy.io @@ -396,6 +625,32 @@ xdsIR: urlRewrite: host: header: my-host + - directResponse: + statusCode: 500 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-invalid-path-regex + namespace: default + name: httproute/default/httproute-invalid-path-regex/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /invalid-path-regex + - directResponse: + statusCode: 500 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-missing-path-regex + namespace: default + name: httproute/default/httproute-missing-path-regex/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /missing-path-regex - directResponse: statusCode: 500 hostname: gateway.envoyproxy.io diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml index 09b7115694..d0a0484b70 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml @@ -161,6 +161,31 @@ httpRoutes: group: gateway.envoyproxy.io kind: HTTPRouteFilter name: not-found-filter +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-6 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/node" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-path-regex httpFilters: - apiVersion: gateway.envoyproxy.io/v1alpha1 kind: HTTPRouteFilter @@ -181,3 +206,15 @@ httpFilters: urlRewrite: hostname: type: Backend +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: valid-path-regex + namespace: default + spec: + urlRewrite: + hostname: + type: PathRegex + pathRegex: + pattern: "^/node/([0-9]+)/api.*" + substitution: "backend-\\1.service.namespace.svc.cluster.local" diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml index 0dca8c386c..d08cd97665 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml @@ -16,7 +16,7 @@ gateways: protocol: HTTP status: listeners: - - attachedRoutes: 5 + - attachedRoutes: 6 conditions: - lastTransitionTime: null message: Sending translated listener configuration to the data plane @@ -278,6 +278,49 @@ httpRoutes: name: gateway-1 namespace: envoy-gateway sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-6 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-path-regex + type: ExtensionRef + matches: + - path: + value: /node + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http infraIR: envoy-gateway/gateway-1: proxy: @@ -494,6 +537,41 @@ xdsIR: urlRewrite: host: header: my-host + - destination: + metadata: + kind: HTTPRoute + name: httproute-6 + namespace: default + name: httproute/default/httproute-6/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-6/rule/0/backend/0 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-6 + namespace: default + name: httproute/default/httproute-6/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /node + urlRewrite: + host: + pathRegex: + pattern: ^/node/([0-9]+)/api.* + substitution: backend-\1.service.namespace.svc.cluster.local readyListener: address: 0.0.0.0 ipFamily: IPv4 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index c8ec2b573d..e2bf27ff15 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -58,7 +58,8 @@ var ( ErrHTTPPathModifierDoubleReplace = errors.New("redirect filter cannot have a path modifier that supplies more than one of fullPathReplace, prefixMatchReplace and regexMatchReplace") ErrHTTPPathModifierNoReplace = errors.New("redirect filter cannot have a path modifier that does not supply either fullPathReplace, prefixMatchReplace or regexMatchReplace") ErrHTTPPathRegexModifierNoSetting = errors.New("redirect filter cannot have a path modifier that does not supply either fullPathReplace, prefixMatchReplace or regexMatchReplace") - ErrHTTPHostModifierDoubleReplace = errors.New("redirect filter cannot have a host modifier that supplies more than one of Hostname, Header and Backend") + ErrHTTPHostModifierDoubleReplace = errors.New("url rewrite filter cannot have a host modifier that supplies more than one of Name, Header, Backend and PathRegex") + ErrHTTPHostModifierEmptyPathRegex = errors.New("host modifier with a PathRegex must supply both a Pattern and a Substitution") ErrAddHeaderEmptyName = errors.New("header modifier filter cannot configure a header without a name to be added") ErrAddHeaderDuplicate = errors.New("header modifier filter attempts to add the same header more than once (case insensitive)") ErrRemoveHeaderDuplicate = errors.New("header modifier filter attempts to remove the same header more than once (case insensitive)") @@ -2322,16 +2323,17 @@ func (r ExtendedHTTPPathModifier) Validate() error { // +k8s:deepcopy-gen=true type HTTPHostModifier struct { // Name provides a string to replace the host of the request. - Name *string `json:"name,omitempty" yaml:"name,omitempty"` - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Backend *bool `json:"backend,omitempty" yaml:"backend,omitempty"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + Backend *bool `json:"backend,omitempty" yaml:"backend,omitempty"` + PathRegex *RegexMatchReplace `json:"pathRegex,omitempty" yaml:"pathRegex,omitempty"` } // Validate the fields within the HTTPPathModifier structure func (r HTTPHostModifier) Validate() error { var errs error - rewrites := []bool{r.Name != nil, r.Header != nil, r.Backend != nil} + rewrites := []bool{r.Name != nil, r.Header != nil, r.Backend != nil, r.PathRegex != nil} rwc := 0 for _, rw := range rewrites { if rw { @@ -2343,6 +2345,10 @@ func (r HTTPHostModifier) Validate() error { errs = errors.Join(errs, ErrHTTPHostModifierDoubleReplace) } + if r.PathRegex != nil && (r.PathRegex.Pattern == "" || r.PathRegex.Substitution == "") { + errs = errors.Join(errs, ErrHTTPHostModifierEmptyPathRegex) + } + return errs } diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index fd9e5ce9fe..0a6c43218d 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -2279,6 +2279,11 @@ func (in *HTTPHostModifier) DeepCopyInto(out *HTTPHostModifier) { *out = new(bool) **out = **in } + if in.PathRegex != nil { + in, out := &in.PathRegex, &out.PathRegex + *out = new(RegexMatchReplace) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPHostModifier. diff --git a/internal/xds/translator/route.go b/internal/xds/translator/route.go index 21eb69951c..9c7e741be3 100644 --- a/internal/xds/translator/route.go +++ b/internal/xds/translator/route.go @@ -592,6 +592,15 @@ func buildXdsURLRewriteAction(route *ir.HTTPRoute, urlRewrite *ir.URLRewrite, pa routeAction.HostRewriteSpecifier = &routev3.RouteAction_AutoHostRewrite{ AutoHostRewrite: wrapperspb.Bool(true), } + case urlRewrite.Host.PathRegex != nil: + routeAction.HostRewriteSpecifier = &routev3.RouteAction_HostRewritePathRegex{ + HostRewritePathRegex: &matcherv3.RegexMatchAndSubstitute{ + Pattern: &matcherv3.RegexMatcher{ + Regex: urlRewrite.Host.PathRegex.Pattern, + }, + Substitution: urlRewrite.Host.PathRegex.Substitution, + }, + } } if urlRewrite.AppendXForwardedHost == nil || *urlRewrite.AppendXForwardedHost { diff --git a/internal/xds/translator/route_test.go b/internal/xds/translator/route_test.go index af72994b03..4c1efdb5f1 100644 --- a/internal/xds/translator/route_test.go +++ b/internal/xds/translator/route_test.go @@ -363,3 +363,60 @@ func TestBuildXdsURLRewriteAction_AppendXForwardedHost(t *testing.T) { }) } } + +func TestBuildXdsURLRewriteAction_PathRegexHostRewrite(t *testing.T) { + baseRoute := &ir.HTTPRoute{ + Name: "test-route", + Destination: &ir.RouteDestination{ + Name: "test-dest", + Settings: []*ir.DestinationSetting{ + { + Endpoints: []*ir.DestinationEndpoint{ + {Host: "1.2.3.4", Port: 8080}, + }, + }, + }, + }, + } + + tests := []struct { + name string + appendXForwardedHost *bool + wantAppendXForwardedHost bool + }{ + { + name: "nil defaults to true", + appendXForwardedHost: nil, + wantAppendXForwardedHost: true, + }, + { + name: "explicit false", + appendXForwardedHost: new(false), + wantAppendXForwardedHost: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + urlRewrite := &ir.URLRewrite{ + Host: &ir.HTTPHostModifier{ + PathRegex: &ir.RegexMatchReplace{ + Pattern: "^/node/([0-9]+)/api.*", + Substitution: "backend-\\1.service.namespace.svc.cluster.local", + }, + }, + AppendXForwardedHost: tt.appendXForwardedHost, + } + got := buildXdsURLRewriteAction(baseRoute, urlRewrite, nil) + if got.AppendXForwardedHost != tt.wantAppendXForwardedHost { + t.Errorf("AppendXForwardedHost = %v, want %v", got.AppendXForwardedHost, tt.wantAppendXForwardedHost) + } + if got.GetHostRewritePathRegex().GetPattern().GetRegex() != "^/node/([0-9]+)/api.*" { + t.Errorf("HostRewritePathRegex pattern = %v, want %v", got.GetHostRewritePathRegex().GetPattern().GetRegex(), "^/node/([0-9]+)/api.*") + } + if got.GetHostRewritePathRegex().GetSubstitution() != "backend-\\1.service.namespace.svc.cluster.local" { + t.Errorf("HostRewritePathRegex substitution = %v, want %v", got.GetHostRewritePathRegex().GetSubstitution(), "backend-\\1.service.namespace.svc.cluster.local") + } + }) + } +} diff --git a/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-host.yaml b/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-host.yaml index b1ab28b1ef..d7d4fcd6cd 100644 --- a/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-host.yaml +++ b/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-host.yaml @@ -65,6 +65,27 @@ http: backend: true path: prefixMatchReplace: /rewrite + - name: "rewrite-host-path-regex" + pathMatch: + prefix: "/node" + hostname: gateway.envoyproxy.io + headerMatches: + - name: ":authority" + exact: gateway.envoyproxy.io + destination: + name: "rewrite-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + name: "rewrite-route-dest/backend/0" + urlRewrite: + host: + pathRegex: + pattern: "^/node/([0-9]+)/api.*" + substitution: "backend-\\1.service.namespace.svc.cluster.local" + path: + prefixMatchReplace: /rewrite - name: "rewrite-route-timeout" pathMatch: prefix: "/origin" diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-host.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-host.routes.yaml index 69ea825367..4d8d0185c5 100644 --- a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-host.routes.yaml +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-host.routes.yaml @@ -47,6 +47,23 @@ prefixRewrite: /rewrite upgradeConfigs: - upgradeType: websocket + - match: + headers: + - name: :authority + stringMatch: + exact: gateway.envoyproxy.io + pathSeparatedPrefix: /node + name: rewrite-host-path-regex + route: + appendXForwardedHost: true + cluster: rewrite-route-dest + hostRewritePathRegex: + pattern: + regex: ^/node/([0-9]+)/api.* + substitution: backend-\1.service.namespace.svc.cluster.local + prefixRewrite: /rewrite + upgradeConfigs: + - upgradeType: websocket - match: headers: - name: :authority diff --git a/release-notes/current/new_features/8964-httproutefilter-pathregex-hostname-rewrite.md b/release-notes/current/new_features/8964-httproutefilter-pathregex-hostname-rewrite.md new file mode 100644 index 0000000000..72d68f9e8a --- /dev/null +++ b/release-notes/current/new_features/8964-httproutefilter-pathregex-hostname-rewrite.md @@ -0,0 +1 @@ +Added support for `HTTPRouteFilter` PathRegex hostname rewrites, allowing the upstream Host header to be derived from regex captures on the request path. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 66d6c6cd5a..f9228ac372 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -3107,6 +3107,7 @@ _Appears in:_ | --- | --- | --- | --- | --- | | `type` | _[HTTPHostnameModifierType](#httphostnamemodifiertype)_ | true | | | | `header` | _string_ | false | | Header is the name of the header whose value would be used to rewrite the Host header | +| `pathRegex` | _[HostnamePathRegexRewrite](#hostnamepathregexrewrite)_ | false | | PathRegex defines a regex match and substitution applied to the request path to compute
the rewritten Host header.
For example, with:
pathRegex:
pattern: "^/tenant/([a-z0-9-]+)/.*"
substitution: "\\1.example.internal"
a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten
to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). | #### HTTPHostnameModifierType @@ -3122,6 +3123,7 @@ _Appears in:_ | ----- | ----------- | | `Header` | HeaderHTTPHostnameModifier indicates that the Host header value would be replaced with the value of the header specified in header.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-host-rewrite-header
| | `Backend` | BackendHTTPHostnameModifier indicates that the Host header value would be replaced by the DNS name of the backend if it exists.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-auto-host-rewrite
| +| `PathRegex` | PathRegexHTTPHostnameModifier indicates that the Host header value would be rewritten by applying a regex
match and substitution to the request path.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-host-rewrite-path-regex
| #### HTTPPathModifier @@ -3411,6 +3413,21 @@ _Appears in:_ | `stripTrailingHostDot` | _boolean_ | false | | StripTrailingHostDot determines if the trailing dot of the host should be removed
from the Host/Authority header before any processing of the request.
This affects the upstream host header as well. Without this option, incoming requests
with host "example.com." will not match routes with domains set to "example.com".
When the host includes a port (for example "example.com.:443"), only the trailing dot
from the host section is stripped, leaving the port as-is ("example.com:443").
Defaults to false. | +#### HostnamePathRegexRewrite + + + +HostnamePathRegexRewrite defines a hostname rewrite computed from the request path using regex. + +_Appears in:_ +- [HTTPHostnameModifier](#httphostnamemodifier) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `pattern` | _string_ | true | | Pattern matches a regular expression against the value of the HTTP Path. The regex string must
adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. | +| `substitution` | _string_ | true | | Substitution is an expression that replaces the matched portion. The expression may include numbered
capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax.
The resulting value is used as the upstream Host header and should be constrained to a valid
DNS hostname by using explicit regex capture groups in Pattern.
The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are
rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the
generated configuration to be rejected by the data plane. | + + #### IPEndpoint diff --git a/test/cel-validation/httproutefilter_test.go b/test/cel-validation/httproutefilter_test.go index 693c28672a..8024d6a940 100644 --- a/test/cel-validation/httproutefilter_test.go +++ b/test/cel-validation/httproutefilter_test.go @@ -115,6 +115,42 @@ func TestHTTPRouteFilter(t *testing.T) { }, wantErrors: []string{}, }, + { + desc: "Valid PathRegex", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.PathRegexHTTPHostnameModifier, + PathRegex: &egv1a1.HostnamePathRegexRewrite{ + Pattern: "^/node/([0-9]+)/api.*", + Substitution: "backend-\\1.service.namespace.svc.cluster.local", + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "invalid PathRegex substitution with control characters", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.PathRegexHTTPHostnameModifier, + PathRegex: &egv1a1.HostnamePathRegexRewrite{ + Pattern: "^/node/([0-9]+)/api.*", + Substitution: "backend-\\1.service\r\n.local", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.hostname.pathRegex.substitution in body should match '^[^\\r\\n\\x00]*$'", + }, + }, { desc: "Valid appendXForwardedHost false", mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { @@ -178,6 +214,103 @@ func TestHTTPRouteFilter(t *testing.T) { ": header must be nil if the type is not Header", }, }, + { + desc: "invalid PathRegex missing settings", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.PathRegexHTTPHostnameModifier, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.hostname: Invalid value:", + ": pathRegex must be specified for PathRegex type", + }, + }, + { + desc: "invalid PathRegex with header", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.PathRegexHTTPHostnameModifier, + Header: new("foo"), + PathRegex: &egv1a1.HostnamePathRegexRewrite{ + Pattern: "^/node/([0-9]+)/api.*", + Substitution: "backend-\\1.service.namespace.svc.cluster.local", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.hostname: Invalid value:", + ": header must be nil if the type is not Header", + }, + }, + { + desc: "invalid Header with pathRegex", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.HeaderHTTPHostnameModifier, + Header: new("foo"), + PathRegex: &egv1a1.HostnamePathRegexRewrite{ + Pattern: "^/node/([0-9]+)/api.*", + Substitution: "backend-\\1.service.namespace.svc.cluster.local", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.hostname: Invalid value:", + ": pathRegex must be nil if the type is not PathRegex", + }, + }, + { + desc: "invalid Backend with pathRegex", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.BackendHTTPHostnameModifier, + PathRegex: &egv1a1.HostnamePathRegexRewrite{ + Pattern: "^/node/([0-9]+)/api.*", + Substitution: "backend-\\1.service.namespace.svc.cluster.local", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.hostname: Invalid value:", + ": pathRegex must be nil if the type is not PathRegex", + }, + }, + { + desc: "invalid PathRegex missing pattern and substitution", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Hostname: &egv1a1.HTTPHostnameModifier{ + Type: egv1a1.PathRegexHTTPHostnameModifier, + PathRegex: &egv1a1.HostnamePathRegexRewrite{ + Pattern: "", + Substitution: "", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.hostname.pathRegex.pattern: Invalid value: \"\": spec.urlRewrite.hostname.pathRegex.pattern in body should be at least 1 chars long", + }, + }, { desc: "Valid DirectResponse with header add", mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { diff --git a/test/e2e/testdata/httproute-rewrite-host.yaml b/test/e2e/testdata/httproute-rewrite-host.yaml index 871b2008b3..9832e3e76f 100644 --- a/test/e2e/testdata/httproute-rewrite-host.yaml +++ b/test/e2e/testdata/httproute-rewrite-host.yaml @@ -34,6 +34,19 @@ spec: - group: gateway.envoyproxy.io kind: Backend name: backend-fqdn + - matches: + - path: + type: PathPrefix + value: /path-regex + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: path-regex-host-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 --- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: HTTPRouteFilter @@ -57,6 +70,19 @@ spec: type: Backend --- apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: HTTPRouteFilter +metadata: + name: path-regex-host-rewrite + namespace: gateway-conformance-infra +spec: + urlRewrite: + hostname: + type: PathRegex + pathRegex: + pattern: "^/path-regex/([^/]+).*" + substitution: "\\1.example.internal" +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 kind: Backend metadata: name: backend-fqdn diff --git a/test/e2e/tests/httproute_rewrite_host.go b/test/e2e/tests/httproute_rewrite_host.go index 49353e4afe..f6dbfc1532 100644 --- a/test/e2e/tests/httproute_rewrite_host.go +++ b/test/e2e/tests/httproute_rewrite_host.go @@ -62,6 +62,19 @@ var HTTPRouteRewriteHostHeader = suite.ConformanceTest{ Backend: "infra-backend-v1", Namespace: ns, }, + { + Request: http.Request{ + Path: "/path-regex/custom-host", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/path-regex/custom-host", + Host: "custom-host.example.internal", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, } for i := range testCases { // Declare tc here to avoid loop variable diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 31f9836eeb..a407ed1343 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -52514,12 +52514,48 @@ spec: description: Header is the name of the header whose value would be used to rewrite the Host header type: string + pathRegex: + description: |- + PathRegex defines a regex match and substitution applied to the request path to compute + the rewritten Host header. + For example, with: + pathRegex: + pattern: "^/tenant/([a-z0-9-]+)/.*" + substitution: "\\1.example.internal" + a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten + to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). + properties: + pattern: + description: |- + Pattern matches a regular expression against the value of the HTTP Path. The regex string must + adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 + type: string + substitution: + description: |- + Substitution is an expression that replaces the matched portion. The expression may include numbered + capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + + The resulting value is used as the upstream Host header and should be constrained to a valid + DNS hostname by using explicit regex capture groups in Pattern. + + The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are + rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the + generated configuration to be rejected by the data plane. + minLength: 1 + pattern: ^[^\r\n\x00]*$ + type: string + required: + - pattern + - substitution + type: object type: description: HTTPPathModifierType defines the type of Hostname rewrite. enum: - Header - Backend + - PathRegex type: string required: - type @@ -52529,6 +52565,10 @@ spec: rule: '!(has(self.header) && self.type != ''Header'')' - message: header must be specified for Header type rule: '!(!has(self.header) && self.type == ''Header'')' + - message: pathRegex must be nil if the type is not PathRegex + rule: '!(has(self.pathRegex) && self.type != ''PathRegex'')' + - message: pathRegex must be specified for PathRegex type + rule: '!(!has(self.pathRegex) && self.type == ''PathRegex'')' path: description: Path defines a path rewrite. properties: diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index 02b6065f79..446d11508e 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -28452,12 +28452,48 @@ spec: description: Header is the name of the header whose value would be used to rewrite the Host header type: string + pathRegex: + description: |- + PathRegex defines a regex match and substitution applied to the request path to compute + the rewritten Host header. + For example, with: + pathRegex: + pattern: "^/tenant/([a-z0-9-]+)/.*" + substitution: "\\1.example.internal" + a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten + to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). + properties: + pattern: + description: |- + Pattern matches a regular expression against the value of the HTTP Path. The regex string must + adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 + type: string + substitution: + description: |- + Substitution is an expression that replaces the matched portion. The expression may include numbered + capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + + The resulting value is used as the upstream Host header and should be constrained to a valid + DNS hostname by using explicit regex capture groups in Pattern. + + The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are + rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the + generated configuration to be rejected by the data plane. + minLength: 1 + pattern: ^[^\r\n\x00]*$ + type: string + required: + - pattern + - substitution + type: object type: description: HTTPPathModifierType defines the type of Hostname rewrite. enum: - Header - Backend + - PathRegex type: string required: - type @@ -28467,6 +28503,10 @@ spec: rule: '!(has(self.header) && self.type != ''Header'')' - message: header must be specified for Header type rule: '!(!has(self.header) && self.type == ''Header'')' + - message: pathRegex must be nil if the type is not PathRegex + rule: '!(has(self.pathRegex) && self.type != ''PathRegex'')' + - message: pathRegex must be specified for PathRegex type + rule: '!(!has(self.pathRegex) && self.type == ''PathRegex'')' path: description: Path defines a path rewrite. properties: diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index 0b1d7764f0..7d026c9ae9 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -28452,12 +28452,48 @@ spec: description: Header is the name of the header whose value would be used to rewrite the Host header type: string + pathRegex: + description: |- + PathRegex defines a regex match and substitution applied to the request path to compute + the rewritten Host header. + For example, with: + pathRegex: + pattern: "^/tenant/([a-z0-9-]+)/.*" + substitution: "\\1.example.internal" + a request to "http://foo.bar.com/tenant/tenant1/api/v1" has its upstream Host header rewritten + to "tenant1.example.internal" (the request path "/tenant/tenant1/api/v1" is preserved). + properties: + pattern: + description: |- + Pattern matches a regular expression against the value of the HTTP Path. The regex string must + adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 + type: string + substitution: + description: |- + Substitution is an expression that replaces the matched portion. The expression may include numbered + capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + + The resulting value is used as the upstream Host header and should be constrained to a valid + DNS hostname by using explicit regex capture groups in Pattern. + + The NUL, CR, and LF characters are not allowed: they are invalid in an HTTP header value and are + rejected by the Envoy proto (well_known_regex HTTP_HEADER_VALUE), which would otherwise cause the + generated configuration to be rejected by the data plane. + minLength: 1 + pattern: ^[^\r\n\x00]*$ + type: string + required: + - pattern + - substitution + type: object type: description: HTTPPathModifierType defines the type of Hostname rewrite. enum: - Header - Backend + - PathRegex type: string required: - type @@ -28467,6 +28503,10 @@ spec: rule: '!(has(self.header) && self.type != ''Header'')' - message: header must be specified for Header type rule: '!(!has(self.header) && self.type == ''Header'')' + - message: pathRegex must be nil if the type is not PathRegex + rule: '!(has(self.pathRegex) && self.type != ''PathRegex'')' + - message: pathRegex must be specified for PathRegex type + rule: '!(!has(self.pathRegex) && self.type == ''PathRegex'')' path: description: Path defines a path rewrite. properties: