From ec08a2f442dcf078d55dcf48e21adc699024272d Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 15:30:15 +0100 Subject: [PATCH 1/7] fix: respect user Host header override on HTTPProxy backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTPProxy controller unconditionally injected a URLRewrite.Hostname filter for FQDN backends (and HTTPS-IP backends with tls.hostname), clobbering any user-supplied Host override via requestHeaderModifier. Envoy applies host_rewrite_literal after request_headers_to_add, so the implicit rewrite always won regardless of filter ordering or whether the user wrote the override at rule or backend level. This matches the shape documented in the engineering wiki (Set-Host-Header-With-datumctl) — the controller now leaves user overrides intact. Key changes: - Add hasHostHeaderOverride helper that detects a RequestHeaderModifier setting Host (case-insensitive per RFC 7230) - Skip URLRewrite injection in both FQDN and HTTPS-IP branches when the rule or backend already specifies a Host override - Cover FQDN rule-level, FQDN backend-level, HTTPS-IP rule-level, and case-insensitive header name matching with unit tests No behaviour change for HTTPProxy resources without a Host override. --- internal/controller/httpproxy_controller.go | 57 ++++++++---- .../controller/httpproxy_controller_test.go | 90 +++++++++++++++++++ 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index bb76054..c522cd2 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -624,6 +624,23 @@ func httpProxyReferencesConnector(httpProxy *networkingv1alpha.HTTPProxy, connec return false } +// hasHostHeaderOverride reports whether any filter in the list is a +// RequestHeaderModifier that sets the Host header. Header names are matched +// case-insensitively per RFC 7230. +func hasHostHeaderOverride(filters []gatewayv1.HTTPRouteFilter) bool { + for _, filter := range filters { + if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier || filter.RequestHeaderModifier == nil { + continue + } + for _, h := range filter.RequestHeaderModifier.Set { + if strings.EqualFold(string(h.Name), "Host") { + return true + } + } + } + return false +} + func (r *HTTPProxyReconciler) collectDesiredResources( ctx context.Context, cl client.Client, @@ -782,30 +799,38 @@ func (r *HTTPProxyReconciler) collectDesiredResources( } } + // If the user already overrode the Host header via a RequestHeaderModifier + // filter (on the rule or this backend), respect it and skip the + // implicit URLRewrite injection that would clobber it via Envoy's + // host_rewrite_literal (applied after request_headers_to_add). + userSetHost := hasHostHeaderOverride(ruleFilters) || hasHostHeaderOverride(backend.Filters) + // For HTTPS endpoints with IP addresses, require tls.hostname for certificate validation // and use it as the Host header for the upstream request. if u.Scheme == "https" && isIPAddress { if backend.TLS == nil || backend.TLS.Hostname == nil || *backend.TLS.Hostname == "" { return nil, fmt.Errorf("HTTPS endpoint with IP address requires tls.hostname for backend %d in rule %d", backendIndex, ruleIndex) } - // Use tls.hostname for the Host header rewrite - hostnameRewriteFound := false - for i, filter := range ruleFilters { - if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { - ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)) - hostnameRewriteFound = true - break + if !userSetHost { + // Use tls.hostname for the Host header rewrite + hostnameRewriteFound := false + for i, filter := range ruleFilters { + if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { + ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)) + hostnameRewriteFound = true + break + } + } + if !hostnameRewriteFound { + ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Hostname: ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)), + }, + }) } } - if !hostnameRewriteFound { - ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ - Type: gatewayv1.HTTPRouteFilterURLRewrite, - URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ - Hostname: ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)), - }, - }) - } - } else if !isIPAddress && backend.Connector == nil { + } else if !isIPAddress && backend.Connector == nil && !userSetHost { // For FQDN endpoints, rewrite the Host header to match the backend hostname hostnameRewriteFound := false for i, filter := range ruleFilters { diff --git a/internal/controller/httpproxy_controller_test.go b/internal/controller/httpproxy_controller_test.go index 129ad68..2e84f0c 100644 --- a/internal/controller/httpproxy_controller_test.go +++ b/internal/controller/httpproxy_controller_test.go @@ -276,6 +276,96 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { } }, }, + { + name: "user Host header override on FQDN backend skips URLRewrite injection", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "Host", Value: "example.internal"}, + }, + }, + }, + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + routeRule := desiredResources.httpRoute.Spec.Rules[0] + for _, f := range routeRule.Filters { + assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type, "URLRewrite must not be injected when user has set Host header") + } + if assert.Len(t, routeRule.Filters, 1) { + assert.Equal(t, gatewayv1.HTTPRouteFilterRequestHeaderModifier, routeRule.Filters[0].Type) + assert.Equal(t, "example.internal", routeRule.Filters[0].RequestHeaderModifier.Set[0].Value) + } + }, + }, + { + name: "user Host header override is case-insensitive", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "host", Value: "example.internal"}, + }, + }, + }, + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + for _, f := range desiredResources.httpRoute.Spec.Rules[0].Filters { + assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type) + } + }, + }, + { + name: "user Host header override at backend level skips URLRewrite injection", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = nil + h.Spec.Rules[0].Backends[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "Host", Value: "example.internal"}, + }, + }, + }, + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + for _, f := range desiredResources.httpRoute.Spec.Rules[0].Filters { + assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type, "URLRewrite must not be injected when user has set Host at backend level") + } + }, + }, + { + name: "user Host header override on HTTPS IP backend skips URLRewrite injection", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "Host", Value: "example.internal"}, + }, + }, + }, + } + h.Spec.Rules[0].Backends[0].Endpoint = "https://192.168.1.1" + h.Spec.Rules[0].Backends[0].TLS = &networkingv1alpha.HTTPProxyBackendTLS{ + Hostname: ptr.To("api.example.com"), + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + for _, f := range desiredResources.httpRoute.Spec.Rules[0].Filters { + assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type) + } + }, + }, { name: "custom hostnames", httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { From 7b99fcba73cb9e28387f7cacd42aad30868ed6cc Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 15:35:54 +0100 Subject: [PATCH 2/7] fix: use distinct IP in new test to satisfy goconst lint --- internal/controller/httpproxy_controller_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/httpproxy_controller_test.go b/internal/controller/httpproxy_controller_test.go index 2e84f0c..8d1e0dd 100644 --- a/internal/controller/httpproxy_controller_test.go +++ b/internal/controller/httpproxy_controller_test.go @@ -355,7 +355,7 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { }, }, } - h.Spec.Rules[0].Backends[0].Endpoint = "https://192.168.1.1" + h.Spec.Rules[0].Backends[0].Endpoint = "https://10.0.0.1" h.Spec.Rules[0].Backends[0].TLS = &networkingv1alpha.HTTPProxyBackendTLS{ Hostname: ptr.To("api.example.com"), } From 25e54f3200389994c1845aacb607878d6c9df33f Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 16:46:50 +0100 Subject: [PATCH 3/7] chore: force rebuild to bust stale GHA cache From a506fb5861d5b09b7f8c3b8a6eef1cca6d80f013 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 16:49:54 +0100 Subject: [PATCH 4/7] chore: expand comment on hasHostHeaderOverride to force docker build --- internal/controller/httpproxy_controller.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index c522cd2..82b6a2a 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -627,6 +627,9 @@ func httpProxyReferencesConnector(httpProxy *networkingv1alpha.HTTPProxy, connec // hasHostHeaderOverride reports whether any filter in the list is a // RequestHeaderModifier that sets the Host header. Header names are matched // case-insensitively per RFC 7230. +// +// Used by collectDesiredResources to suppress the implicit URLRewrite +// hostname injection when the user has already supplied a Host override. func hasHostHeaderOverride(filters []gatewayv1.HTTPRouteFilter) bool { for _, filter := range filters { if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier || filter.RequestHeaderModifier == nil { From 45ce1e365048b504965626c09a42d1b3a20a27f1 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 17:30:41 +0100 Subject: [PATCH 5/7] fix: derive BackendTLSPolicy hostname from EndpointSlice when URLRewrite absent When the user supplies a Host header override via requestHeaderModifier on an HTTPProxy, the previous commit teaches the httpproxy controller to suppress the implicit URLRewrite hostname injection so the override is honoured at egress. That broke downstream HTTPRoute creation for HTTPS backends because the gateway controller relied on the same URLRewrite filter to source the BackendTLSPolicy hostname for cert validation. Fall back to the upstream EndpointSlice's FQDN address (which the httpproxy controller already sets to the backend hostname) when no URLRewrite filter is present. This is the same hostname that would have been used by the implicit URLRewrite injection, so cert validation behaviour is unchanged. --- internal/controller/gateway_controller.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/controller/gateway_controller.go b/internal/controller/gateway_controller.go index 86dc883..d812fe8 100644 --- a/internal/controller/gateway_controller.go +++ b/internal/controller/gateway_controller.go @@ -1860,7 +1860,7 @@ func (r *GatewayReconciler) processDownstreamHTTPRouteRules( if appProtocol != nil && *appProtocol == "https" { var hostname *gatewayv1.PreciseHostname - // Fall back to looking at rule filters for a hostname. + // Look at rule filters for a hostname first. for _, filter := range rule.Filters { if filter.URLRewrite != nil { hostname = filter.URLRewrite.Hostname @@ -1868,10 +1868,23 @@ func (r *GatewayReconciler) processDownstreamHTTPRouteRules( } } + // When the user has supplied a Host header override via + // RequestHeaderModifier, the httpproxy controller intentionally + // suppresses the implicit URLRewrite injection so the user's + // Host value is honoured at egress. In that case, derive the + // BackendTLSPolicy hostname directly from the upstream + // EndpointSlice's FQDN address — that's the cert SAN we need + // to validate against the backend. + if hostname == nil && upstreamEndpointSlice.AddressType == discoveryv1.AddressTypeFQDN && + len(upstreamEndpointSlice.Endpoints) > 0 && + len(upstreamEndpointSlice.Endpoints[0].Addresses) > 0 { + hostname = ptr.To(gatewayv1.PreciseHostname(upstreamEndpointSlice.Endpoints[0].Addresses[0])) + } + if hostname == nil { // TODO(jreese) set the RouteConditionResolvedRefs condition to // False, as the hostname is not present. - return nil, nil, nil, fmt.Errorf("no hostname found in URLRewrite filters on backendRef or Route %q", upstreamRoute.Name) + return nil, nil, nil, fmt.Errorf("no hostname found in URLRewrite filters or EndpointSlice on backendRef or Route %q", upstreamRoute.Name) } backendTLSPolicy := &gatewayv1alpha3.BackendTLSPolicy{ From e9e89e270a87dc6237dd09bdb893791a76694e54 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 17:44:19 +0100 Subject: [PATCH 6/7] fix: rewrite user Host override into URLRewrite, surface cert hostname via EndpointSlice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Envoy Gateway rejects RequestHeaderModifier filters that touch the Host header (status condition: "RequestHeaderModifier Filter did not provide valid configuration to add/set/remove any headers"). The previous revisions of this fix wrote the user override as a RequestHeaderModifier filter and skipped URLRewrite injection — EG dropped the route as unaccepted and the gateway never attached it, returning 404. Translate the user-facing RequestHeaderModifier{Host: X} shape (which datumctl and the cloud portal write) into the URLRewrite.Hostname value Envoy actually honours at egress, and strip the now-redundant Host entry from the RequestHeaderModifier so EG doesn't see the conflict. URLRewrite.Hostname previously doubled as the BackendTLSPolicy SAN hostname for cert validation, which broke once URLRewrite started carrying user overrides instead of the real backend FQDN. Surface the cert hostname via a new BackendCertHostnameAnnotation on the upstream EndpointSlice; the gateway controller prefers it over URLRewrite when building the BackendTLSPolicy, falling back to URLRewrite for EndpointSlices that predate the annotation. Unit tests updated and extended (FQDN rule-level, backend-level, case-insensitive Host match, HTTPS-IP) — all assertions verify that URLRewrite carries the user's value, RequestHeaderModifier{Host} is stripped, and the cert hostname annotation captures the real backend SAN. --- internal/controller/gateway_controller.go | 37 +++-- internal/controller/httpproxy_controller.go | 157 ++++++++++++++---- .../controller/httpproxy_controller_test.go | 80 +++++++-- 3 files changed, 206 insertions(+), 68 deletions(-) diff --git a/internal/controller/gateway_controller.go b/internal/controller/gateway_controller.go index d812fe8..1210673 100644 --- a/internal/controller/gateway_controller.go +++ b/internal/controller/gateway_controller.go @@ -1860,31 +1860,32 @@ func (r *GatewayReconciler) processDownstreamHTTPRouteRules( if appProtocol != nil && *appProtocol == "https" { var hostname *gatewayv1.PreciseHostname - // Look at rule filters for a hostname first. - for _, filter := range rule.Filters { - if filter.URLRewrite != nil { - hostname = filter.URLRewrite.Hostname - break - } + + // Prefer the cert hostname recorded by the httpproxy + // controller on the upstream EndpointSlice. URLRewrite + // may now carry a user-supplied Host header override + // instead of the backend FQDN, so it's no longer a + // reliable source for BackendTLSPolicy SAN validation. + if v, ok := upstreamEndpointSlice.Annotations[BackendCertHostnameAnnotation]; ok && v != "" { + hostname = ptr.To(gatewayv1.PreciseHostname(v)) } - // When the user has supplied a Host header override via - // RequestHeaderModifier, the httpproxy controller intentionally - // suppresses the implicit URLRewrite injection so the user's - // Host value is honoured at egress. In that case, derive the - // BackendTLSPolicy hostname directly from the upstream - // EndpointSlice's FQDN address — that's the cert SAN we need - // to validate against the backend. - if hostname == nil && upstreamEndpointSlice.AddressType == discoveryv1.AddressTypeFQDN && - len(upstreamEndpointSlice.Endpoints) > 0 && - len(upstreamEndpointSlice.Endpoints[0].Addresses) > 0 { - hostname = ptr.To(gatewayv1.PreciseHostname(upstreamEndpointSlice.Endpoints[0].Addresses[0])) + // Fall back to looking at rule filters for a hostname + // (preserves behaviour for EndpointSlices that predate + // the annotation). + if hostname == nil { + for _, filter := range rule.Filters { + if filter.URLRewrite != nil { + hostname = filter.URLRewrite.Hostname + break + } + } } if hostname == nil { // TODO(jreese) set the RouteConditionResolvedRefs condition to // False, as the hostname is not present. - return nil, nil, nil, fmt.Errorf("no hostname found in URLRewrite filters or EndpointSlice on backendRef or Route %q", upstreamRoute.Name) + return nil, nil, nil, fmt.Errorf("no hostname found in URLRewrite filters or EndpointSlice annotation on backendRef or Route %q", upstreamRoute.Name) } backendTLSPolicy := &gatewayv1alpha3.BackendTLSPolicy{ diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index 82b6a2a..069183b 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -65,6 +65,14 @@ type desiredHTTPProxyResources struct { const httpProxyFinalizer = "networking.datumapis.com/httpproxy-cleanup" const connectorOfflineFilterPrefix = "connector-offline" +// BackendCertHostnameAnnotation is set on the upstream EndpointSlice by the +// HTTPProxy controller to record the hostname expected on the backend's TLS +// certificate. The gateway controller reads it when building a +// BackendTLSPolicy so SAN validation continues to target the real backend +// FQDN even when URLRewrite.Hostname has been redirected to a user-supplied +// Host header override. +const BackendCertHostnameAnnotation = "networking.datumapis.com/backend-cert-hostname" + const ( SchemeHTTP = "http" SchemeHTTPS = "https" @@ -624,24 +632,63 @@ func httpProxyReferencesConnector(httpProxy *networkingv1alpha.HTTPProxy, connec return false } -// hasHostHeaderOverride reports whether any filter in the list is a -// RequestHeaderModifier that sets the Host header. Header names are matched -// case-insensitively per RFC 7230. +// extractHostHeaderOverride returns the Host header value from a +// RequestHeaderModifier filter, if present. Header names are matched +// case-insensitively per RFC 7230. The returned bool indicates whether a +// Host header override was found. // -// Used by collectDesiredResources to suppress the implicit URLRewrite -// hostname injection when the user has already supplied a Host override. -func hasHostHeaderOverride(filters []gatewayv1.HTTPRouteFilter) bool { +// Envoy Gateway does not accept Host header manipulation via +// RequestHeaderModifier — it must go through URLRewrite.Hostname instead. +// collectDesiredResources uses this helper to translate the user-facing +// RequestHeaderModifier{Host} shape (which round-trips with datumctl and +// the cloud portal) into the URLRewrite{Hostname} that Envoy actually +// honours at egress. +func extractHostHeaderOverride(filters []gatewayv1.HTTPRouteFilter) (string, bool) { for _, filter := range filters { if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier || filter.RequestHeaderModifier == nil { continue } for _, h := range filter.RequestHeaderModifier.Set { if strings.EqualFold(string(h.Name), "Host") { - return true + return h.Value, true } } } - return false + return "", false +} + +// stripHostFromRequestHeaderModifier returns the filter list with any +// Host entry removed from each RequestHeaderModifier's Set list. If a +// RequestHeaderModifier ends up empty (no add/set/remove), the filter +// itself is dropped. This keeps Envoy Gateway from rejecting the route +// because of an "empty" RequestHeaderModifier after we've moved the +// Host override into URLRewrite. +func stripHostFromRequestHeaderModifier(filters []gatewayv1.HTTPRouteFilter) []gatewayv1.HTTPRouteFilter { + out := make([]gatewayv1.HTTPRouteFilter, 0, len(filters)) + for _, filter := range filters { + if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier || filter.RequestHeaderModifier == nil { + out = append(out, filter) + continue + } + modifier := filter.RequestHeaderModifier + filtered := make([]gatewayv1.HTTPHeader, 0, len(modifier.Set)) + for _, h := range modifier.Set { + if strings.EqualFold(string(h.Name), "Host") { + continue + } + filtered = append(filtered, h) + } + if len(filtered) == 0 && len(modifier.Add) == 0 && len(modifier.Remove) == 0 { + // Drop the now-empty RequestHeaderModifier filter entirely. + continue + } + newFilter := filter + newModifier := *modifier + newModifier.Set = filtered + newFilter.RequestHeaderModifier = &newModifier + out = append(out, newFilter) + } + return out } func (r *HTTPProxyReconciler) collectDesiredResources( @@ -802,11 +849,30 @@ func (r *HTTPProxyReconciler) collectDesiredResources( } } - // If the user already overrode the Host header via a RequestHeaderModifier - // filter (on the rule or this backend), respect it and skip the - // implicit URLRewrite injection that would clobber it via Envoy's - // host_rewrite_literal (applied after request_headers_to_add). - userSetHost := hasHostHeaderOverride(ruleFilters) || hasHostHeaderOverride(backend.Filters) + // Resolve the user's Host header override, if any. Envoy Gateway + // rejects RequestHeaderModifier filters that touch Host; the Host + // rewrite must be expressed as URLRewrite.Hostname instead. We + // translate the user-facing RequestHeaderModifier{Host} shape + // (which is what datumctl and the cloud portal write) into the + // URLRewrite.Hostname value Envoy will honour at egress, then + // strip the now-redundant Host entry from the RequestHeaderModifier + // so EG doesn't see the conflicting combination. + userHostOverride, hasUserHost := extractHostHeaderOverride(ruleFilters) + if !hasUserHost { + userHostOverride, hasUserHost = extractHostHeaderOverride(backend.Filters) + } + if hasUserHost { + ruleFilters = stripHostFromRequestHeaderModifier(ruleFilters) + backend.Filters = stripHostFromRequestHeaderModifier(backend.Filters) + } + + // Track the backend cert hostname separately from the Host + // rewrite value. The two can diverge when the user sets a Host + // override — URLRewrite.Hostname carries the user's value to + // Envoy, while certHostname (propagated via an EndpointSlice + // annotation and read by the gateway controller) is used for + // BackendTLSPolicy SAN validation against the real backend. + var certHostname string // For HTTPS endpoints with IP addresses, require tls.hostname for certificate validation // and use it as the Host header for the upstream request. @@ -814,31 +880,41 @@ func (r *HTTPProxyReconciler) collectDesiredResources( if backend.TLS == nil || backend.TLS.Hostname == nil || *backend.TLS.Hostname == "" { return nil, fmt.Errorf("HTTPS endpoint with IP address requires tls.hostname for backend %d in rule %d", backendIndex, ruleIndex) } - if !userSetHost { - // Use tls.hostname for the Host header rewrite - hostnameRewriteFound := false - for i, filter := range ruleFilters { - if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { - ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)) - hostnameRewriteFound = true - break - } - } - if !hostnameRewriteFound { - ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ - Type: gatewayv1.HTTPRouteFilterURLRewrite, - URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ - Hostname: ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)), - }, - }) + certHostname = *backend.TLS.Hostname + rewriteHostname := certHostname + if hasUserHost { + rewriteHostname = userHostOverride + } + // Use tls.hostname (or the user override) for the Host header rewrite + hostnameRewriteFound := false + for i, filter := range ruleFilters { + if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { + ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(rewriteHostname)) + hostnameRewriteFound = true + break } } - } else if !isIPAddress && backend.Connector == nil && !userSetHost { - // For FQDN endpoints, rewrite the Host header to match the backend hostname + if !hostnameRewriteFound { + ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Hostname: ptr.To(gatewayv1.PreciseHostname(rewriteHostname)), + }, + }) + } + } else if !isIPAddress && backend.Connector == nil { + // For FQDN endpoints, rewrite the Host header to match the + // backend hostname — or to the user's override if they set + // one via RequestHeaderModifier. + certHostname = host + rewriteHostname := host + if hasUserHost { + rewriteHostname = userHostOverride + } hostnameRewriteFound := false for i, filter := range ruleFilters { if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { - ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(host)) + ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(rewriteHostname)) hostnameRewriteFound = true break } @@ -848,16 +924,25 @@ func (r *HTTPProxyReconciler) collectDesiredResources( ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ Type: gatewayv1.HTTPRouteFilterURLRewrite, URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ - Hostname: ptr.To(gatewayv1.PreciseHostname(host)), + Hostname: ptr.To(gatewayv1.PreciseHostname(rewriteHostname)), }, }) } } + epAnnotations := map[string]string{} + if certHostname != "" { + // Surface the backend cert hostname so the gateway controller + // can build the BackendTLSPolicy without relying on the + // URLRewrite filter (which may now carry a user-supplied Host + // override instead of the real backend FQDN). + epAnnotations[BackendCertHostnameAnnotation] = certHostname + } endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ - Namespace: httpProxy.Namespace, - Name: fmt.Sprintf("%s-%d-%d", httpProxy.Name, ruleIndex, backendIndex), + Namespace: httpProxy.Namespace, + Name: fmt.Sprintf("%s-%d-%d", httpProxy.Name, ruleIndex, backendIndex), + Annotations: epAnnotations, }, AddressType: addressType, Endpoints: []discoveryv1.Endpoint{ diff --git a/internal/controller/httpproxy_controller_test.go b/internal/controller/httpproxy_controller_test.go index 8d1e0dd..cc83387 100644 --- a/internal/controller/httpproxy_controller_test.go +++ b/internal/controller/httpproxy_controller_test.go @@ -277,7 +277,7 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { }, }, { - name: "user Host header override on FQDN backend skips URLRewrite injection", + name: "user Host header override on FQDN backend rewrites URLRewrite hostname", httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ { @@ -292,12 +292,28 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { }), assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { routeRule := desiredResources.httpRoute.Spec.Rules[0] - for _, f := range routeRule.Filters { - assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type, "URLRewrite must not be injected when user has set Host header") + // URLRewrite must carry the user's Host value (Envoy's + // host_rewrite_literal); RequestHeaderModifier{Host} must + // have been stripped (EG rejects it on Host). + var urlRewrite *gatewayv1.HTTPRouteFilter + for i := range routeRule.Filters { + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterURLRewrite { + urlRewrite = &routeRule.Filters[i] + } + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterRequestHeaderModifier && + routeRule.Filters[i].RequestHeaderModifier != nil { + for _, h := range routeRule.Filters[i].RequestHeaderModifier.Set { + assert.NotEqual(t, "Host", string(h.Name), "Host must be stripped from RequestHeaderModifier") + } + } + } + if assert.NotNil(t, urlRewrite, "URLRewrite must be present to express the Host rewrite") { + assert.Equal(t, "example.internal", string(ptr.Deref(urlRewrite.URLRewrite.Hostname, ""))) } - if assert.Len(t, routeRule.Filters, 1) { - assert.Equal(t, gatewayv1.HTTPRouteFilterRequestHeaderModifier, routeRule.Filters[0].Type) - assert.Equal(t, "example.internal", routeRule.Filters[0].RequestHeaderModifier.Set[0].Value) + // Cert hostname must be the real backend FQDN, not the user override. + if assert.Len(t, desiredResources.endpointSlices, 1) { + assert.Equal(t, "www.example.com", + desiredResources.endpointSlices[0].Annotations[BackendCertHostnameAnnotation]) } }, }, @@ -316,13 +332,19 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { } }), assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { - for _, f := range desiredResources.httpRoute.Spec.Rules[0].Filters { - assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type) + routeRule := desiredResources.httpRoute.Spec.Rules[0] + foundURLRewriteWithOverride := false + for _, f := range routeRule.Filters { + if f.Type == gatewayv1.HTTPRouteFilterURLRewrite && + string(ptr.Deref(f.URLRewrite.Hostname, "")) == "example.internal" { + foundURLRewriteWithOverride = true + } } + assert.True(t, foundURLRewriteWithOverride, "URLRewrite must use the user's value (case-insensitive Host match)") }, }, { - name: "user Host header override at backend level skips URLRewrite injection", + name: "user Host header override at backend level rewrites URLRewrite hostname", httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { h.Spec.Rules[0].Filters = nil h.Spec.Rules[0].Backends[0].Filters = []gatewayv1.HTTPRouteFilter{ @@ -337,13 +359,30 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { } }), assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { - for _, f := range desiredResources.httpRoute.Spec.Rules[0].Filters { - assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type, "URLRewrite must not be injected when user has set Host at backend level") + routeRule := desiredResources.httpRoute.Spec.Rules[0] + var urlRewrite *gatewayv1.HTTPRouteFilter + for i := range routeRule.Filters { + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterURLRewrite { + urlRewrite = &routeRule.Filters[i] + } + } + if assert.NotNil(t, urlRewrite) { + assert.Equal(t, "example.internal", string(ptr.Deref(urlRewrite.URLRewrite.Hostname, ""))) + } + // Backend-level filter should have Host stripped too. + for _, br := range routeRule.BackendRefs { + for _, f := range br.Filters { + if f.Type == gatewayv1.HTTPRouteFilterRequestHeaderModifier && f.RequestHeaderModifier != nil { + for _, h := range f.RequestHeaderModifier.Set { + assert.NotEqual(t, "Host", string(h.Name)) + } + } + } } }, }, { - name: "user Host header override on HTTPS IP backend skips URLRewrite injection", + name: "user Host header override on HTTPS IP backend rewrites URLRewrite hostname", httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ { @@ -361,8 +400,21 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { } }), assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { - for _, f := range desiredResources.httpRoute.Spec.Rules[0].Filters { - assert.NotEqual(t, gatewayv1.HTTPRouteFilterURLRewrite, f.Type) + routeRule := desiredResources.httpRoute.Spec.Rules[0] + var urlRewrite *gatewayv1.HTTPRouteFilter + for i := range routeRule.Filters { + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterURLRewrite { + urlRewrite = &routeRule.Filters[i] + } + } + if assert.NotNil(t, urlRewrite) { + // URLRewrite carries the user's Host override. + assert.Equal(t, "example.internal", string(ptr.Deref(urlRewrite.URLRewrite.Hostname, ""))) + } + // Cert hostname annotation must carry the real cert SAN (tls.hostname). + if assert.Len(t, desiredResources.endpointSlices, 1) { + assert.Equal(t, "api.example.com", + desiredResources.endpointSlices[0].Annotations[BackendCertHostnameAnnotation]) } }, }, From 80a8c23d02c7cee76df5d06caf4288d21eb09365 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 13 May 2026 18:00:32 +0100 Subject: [PATCH 7/7] fix: propagate backend cert hostname annotation through EndpointSlice CreateOrUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CreateOrUpdate closure only synced AddressType/Endpoints/Ports, so the BackendCertHostnameAnnotation added in the previous commit never made it onto existing EndpointSlices. Without the annotation the gateway controller falls back to URLRewrite.Hostname — which now carries the user's Host override instead of the real backend FQDN — and the BackendTLSPolicy validates against the wrong SAN, causing upstream 503s because the backend cert doesn't match. --- internal/controller/httpproxy_controller.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index 069183b..03b40c0 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -294,6 +294,19 @@ func (r *HTTPProxyReconciler) Reconcile(ctx context.Context, req mcreconcile.Req endpointSlice.AddressType = desiredEndpointSlice.AddressType endpointSlice.Endpoints = desiredEndpointSlice.Endpoints endpointSlice.Ports = desiredEndpointSlice.Ports + + // Keep the backend cert hostname annotation in sync. The gateway + // controller reads this to build the BackendTLSPolicy when the + // URLRewrite filter carries a user Host override instead of the + // real backend FQDN. + if v, ok := desiredEndpointSlice.Annotations[BackendCertHostnameAnnotation]; ok { + if endpointSlice.Annotations == nil { + endpointSlice.Annotations = map[string]string{} + } + endpointSlice.Annotations[BackendCertHostnameAnnotation] = v + } else { + delete(endpointSlice.Annotations, BackendCertHostnameAnnotation) + } return nil })