diff --git a/api/v1alpha1/clienttrafficpolicy_types.go b/api/v1alpha1/clienttrafficpolicy_types.go
index dd2fff5a3e..202a6954a8 100644
--- a/api/v1alpha1/clienttrafficpolicy_types.go
+++ b/api/v1alpha1/clienttrafficpolicy_types.go
@@ -183,6 +183,11 @@ type HeaderSettings struct {
//
// +optional
LateResponseHeaders *HTTPHeaderFilter `json:"lateResponseHeaders,omitempty"`
+
+ // Host enables managing how the Host/Authority header set by clients can be normalized.
+ //
+ // +optional
+ Host *HostSettings `json:"host,omitempty"`
}
// WithUnderscoresAction configures the action to take when an HTTP header with underscores
diff --git a/api/v1alpha1/hostsettings_types.go b/api/v1alpha1/hostsettings_types.go
new file mode 100644
index 0000000000..94c0197ba9
--- /dev/null
+++ b/api/v1alpha1/hostsettings_types.go
@@ -0,0 +1,21 @@
+// Copyright Envoy Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+package v1alpha1
+
+// HostSettings provides settings that manage how the incoming Host/Authority header
+// set by clients is normalized.
+type HostSettings struct {
+ // 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.
+ //
+ // +optional
+ StripTrailingHostDot *bool `json:"stripTrailingHostDot,omitempty"`
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 2195616a06..bdb9768c6d 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -4879,6 +4879,11 @@ func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) {
*out = new(HTTPHeaderFilter)
(*in).DeepCopyInto(*out)
}
+ if in.Host != nil {
+ in, out := &in.Host, &out.Host
+ *out = new(HostSettings)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderSettings.
@@ -4951,6 +4956,26 @@ func (in *HealthCheckSettings) DeepCopy() *HealthCheckSettings {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostSettings) DeepCopyInto(out *HostSettings) {
+ *out = *in
+ if in.StripTrailingHostDot != nil {
+ in, out := &in.StripTrailingHostDot, &out.StripTrailingHostDot
+ *out = new(bool)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostSettings.
+func (in *HostSettings) DeepCopy() *HostSettings {
+ if in == nil {
+ return nil
+ }
+ out := new(HostSettings)
+ 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_clienttrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
index 8dd19d576d..b873c582aa 100644
--- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
+++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
@@ -499,6 +499,21 @@ spec:
EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
and responses.
type: boolean
+ host:
+ description: Host enables managing how the Host/Authority header
+ set by clients can be normalized.
+ properties:
+ stripTrailingHostDot:
+ description: |-
+ 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.
+ type: boolean
+ type: object
lateResponseHeaders:
description: LateResponseHeaders defines settings for global response
header modification.
diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
index 43ddd97c0a..5b73c4161a 100644
--- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
+++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
@@ -498,6 +498,21 @@ spec:
EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
and responses.
type: boolean
+ host:
+ description: Host enables managing how the Host/Authority header
+ set by clients can be normalized.
+ properties:
+ stripTrailingHostDot:
+ description: |-
+ 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.
+ type: boolean
+ type: object
lateResponseHeaders:
description: LateResponseHeaders defines settings for global response
header modification.
diff --git a/internal/gatewayapi/clienttrafficpolicy.go b/internal/gatewayapi/clienttrafficpolicy.go
index bca57a5a96..588d4ec96e 100644
--- a/internal/gatewayapi/clienttrafficpolicy.go
+++ b/internal/gatewayapi/clienttrafficpolicy.go
@@ -925,6 +925,12 @@ func translateListenerHeaderSettings(headerSettings *egv1a1.HeaderSettings, http
}
}
+ if headerSettings.Host != nil && headerSettings.Host.StripTrailingHostDot != nil {
+ httpIR.Host = &ir.HostSettings{
+ StripTrailingHostDot: *headerSettings.Host.StripTrailingHostDot,
+ }
+ }
+
var errs error
if headerSettings.EarlyRequestHeaders != nil {
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-host-empty.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-host-empty.in.yaml
new file mode 100644
index 0000000000..45d03da803
--- /dev/null
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-host-empty.in.yaml
@@ -0,0 +1,28 @@
+clientTrafficPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+ kind: ClientTrafficPolicy
+ metadata:
+ namespace: envoy-gateway
+ name: target-gateway-1
+ spec:
+ headers:
+ host: {}
+ targetRef:
+ group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gateway-1
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+ kind: Gateway
+ metadata:
+ namespace: envoy-gateway
+ name: gateway-1
+ spec:
+ gatewayClassName: envoy-gateway-class
+ listeners:
+ - name: http-1
+ protocol: HTTP
+ port: 80
+ allowedRoutes:
+ namespaces:
+ from: Same
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-host-empty.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-host-empty.out.yaml
new file mode 100644
index 0000000000..ca66ed6ca7
--- /dev/null
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-host-empty.out.yaml
@@ -0,0 +1,139 @@
+clientTrafficPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+ kind: ClientTrafficPolicy
+ metadata:
+ name: target-gateway-1
+ namespace: envoy-gateway
+ spec:
+ headers:
+ host: {}
+ targetRef:
+ group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gateway-1
+ status:
+ ancestors:
+ - ancestorRef:
+ group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gateway-1
+ namespace: envoy-gateway
+ conditions:
+ - lastTransitionTime: null
+ message: Policy has been accepted.
+ reason: Accepted
+ status: "True"
+ type: Accepted
+ - lastTransitionTime: null
+ message: spec.targetRef is deprecated, use spec.targetRefs instead
+ reason: DeprecatedField
+ status: "True"
+ type: Warning
+ controllerName: gateway.envoyproxy.io/gatewayclass-controller
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+ kind: Gateway
+ metadata:
+ name: gateway-1
+ namespace: envoy-gateway
+ spec:
+ gatewayClassName: envoy-gateway-class
+ listeners:
+ - allowedRoutes:
+ namespaces:
+ from: Same
+ name: http-1
+ port: 80
+ protocol: HTTP
+ status:
+ listeners:
+ - attachedRoutes: 0
+ conditions:
+ - lastTransitionTime: null
+ message: Sending translated listener configuration to the data plane
+ reason: Programmed
+ status: "True"
+ type: Programmed
+ - lastTransitionTime: null
+ message: Listener has been successfully translated
+ reason: Accepted
+ status: "True"
+ type: Accepted
+ - lastTransitionTime: null
+ message: Listener references have been resolved
+ reason: ResolvedRefs
+ status: "True"
+ type: ResolvedRefs
+ name: http-1
+ supportedKinds:
+ - group: gateway.networking.k8s.io
+ kind: HTTPRoute
+ - group: gateway.networking.k8s.io
+ kind: GRPCRoute
+infraIR:
+ envoy-gateway/gateway-1:
+ proxy:
+ listeners:
+ - name: envoy-gateway/gateway-1/http-1
+ ports:
+ - containerPort: 10080
+ name: http-80
+ protocol: HTTP
+ servicePort: 80
+ metadata:
+ labels:
+ gateway.envoyproxy.io/owning-gateway-name: gateway-1
+ gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+ ownerReference:
+ kind: GatewayClass
+ name: envoy-gateway-class
+ name: envoy-gateway/gateway-1
+ namespace: envoy-gateway-system
+xdsIR:
+ envoy-gateway/gateway-1:
+ accessLog:
+ json:
+ - path: /dev/stdout
+ globalResources:
+ proxyServiceCluster:
+ metadata:
+ kind: Service
+ name: envoy-envoy-gateway-gateway-1-196ae069
+ namespace: envoy-gateway-system
+ sectionName: "8080"
+ name: envoy-gateway/gateway-1
+ settings:
+ - addressType: IP
+ endpoints:
+ - host: 7.6.5.4
+ port: 8080
+ zone: zone1
+ metadata:
+ kind: Service
+ name: envoy-envoy-gateway-gateway-1-196ae069
+ namespace: envoy-gateway-system
+ sectionName: "8080"
+ name: envoy-gateway/gateway-1
+ protocol: TCP
+ http:
+ - address: 0.0.0.0
+ externalPort: 80
+ headers:
+ withUnderscoresAction: RejectRequest
+ hostnames:
+ - '*'
+ metadata:
+ kind: Gateway
+ name: gateway-1
+ namespace: envoy-gateway
+ sectionName: http-1
+ name: envoy-gateway/gateway-1/http-1
+ path:
+ escapedSlashesAction: UnescapeAndRedirect
+ mergeSlashes: true
+ port: 10080
+ readyListener:
+ address: 0.0.0.0
+ ipFamily: IPv4
+ path: /ready
+ port: 19003
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-host-strip-trailing-dot.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-host-strip-trailing-dot.in.yaml
new file mode 100644
index 0000000000..e8200f0b55
--- /dev/null
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-host-strip-trailing-dot.in.yaml
@@ -0,0 +1,29 @@
+clientTrafficPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+ kind: ClientTrafficPolicy
+ metadata:
+ namespace: envoy-gateway
+ name: target-gateway-1
+ spec:
+ headers:
+ host:
+ stripTrailingHostDot: true
+ targetRef:
+ group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gateway-1
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+ kind: Gateway
+ metadata:
+ namespace: envoy-gateway
+ name: gateway-1
+ spec:
+ gatewayClassName: envoy-gateway-class
+ listeners:
+ - name: http-1
+ protocol: HTTP
+ port: 80
+ allowedRoutes:
+ namespaces:
+ from: Same
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-host-strip-trailing-dot.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-host-strip-trailing-dot.out.yaml
new file mode 100644
index 0000000000..c21a2f7f8b
--- /dev/null
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-host-strip-trailing-dot.out.yaml
@@ -0,0 +1,142 @@
+clientTrafficPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+ kind: ClientTrafficPolicy
+ metadata:
+ name: target-gateway-1
+ namespace: envoy-gateway
+ spec:
+ headers:
+ host:
+ stripTrailingHostDot: true
+ targetRef:
+ group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gateway-1
+ status:
+ ancestors:
+ - ancestorRef:
+ group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gateway-1
+ namespace: envoy-gateway
+ conditions:
+ - lastTransitionTime: null
+ message: Policy has been accepted.
+ reason: Accepted
+ status: "True"
+ type: Accepted
+ - lastTransitionTime: null
+ message: spec.targetRef is deprecated, use spec.targetRefs instead
+ reason: DeprecatedField
+ status: "True"
+ type: Warning
+ controllerName: gateway.envoyproxy.io/gatewayclass-controller
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+ kind: Gateway
+ metadata:
+ name: gateway-1
+ namespace: envoy-gateway
+ spec:
+ gatewayClassName: envoy-gateway-class
+ listeners:
+ - allowedRoutes:
+ namespaces:
+ from: Same
+ name: http-1
+ port: 80
+ protocol: HTTP
+ status:
+ listeners:
+ - attachedRoutes: 0
+ conditions:
+ - lastTransitionTime: null
+ message: Sending translated listener configuration to the data plane
+ reason: Programmed
+ status: "True"
+ type: Programmed
+ - lastTransitionTime: null
+ message: Listener has been successfully translated
+ reason: Accepted
+ status: "True"
+ type: Accepted
+ - lastTransitionTime: null
+ message: Listener references have been resolved
+ reason: ResolvedRefs
+ status: "True"
+ type: ResolvedRefs
+ name: http-1
+ supportedKinds:
+ - group: gateway.networking.k8s.io
+ kind: HTTPRoute
+ - group: gateway.networking.k8s.io
+ kind: GRPCRoute
+infraIR:
+ envoy-gateway/gateway-1:
+ proxy:
+ listeners:
+ - name: envoy-gateway/gateway-1/http-1
+ ports:
+ - containerPort: 10080
+ name: http-80
+ protocol: HTTP
+ servicePort: 80
+ metadata:
+ labels:
+ gateway.envoyproxy.io/owning-gateway-name: gateway-1
+ gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+ ownerReference:
+ kind: GatewayClass
+ name: envoy-gateway-class
+ name: envoy-gateway/gateway-1
+ namespace: envoy-gateway-system
+xdsIR:
+ envoy-gateway/gateway-1:
+ accessLog:
+ json:
+ - path: /dev/stdout
+ globalResources:
+ proxyServiceCluster:
+ metadata:
+ kind: Service
+ name: envoy-envoy-gateway-gateway-1-196ae069
+ namespace: envoy-gateway-system
+ sectionName: "8080"
+ name: envoy-gateway/gateway-1
+ settings:
+ - addressType: IP
+ endpoints:
+ - host: 7.6.5.4
+ port: 8080
+ zone: zone1
+ metadata:
+ kind: Service
+ name: envoy-envoy-gateway-gateway-1-196ae069
+ namespace: envoy-gateway-system
+ sectionName: "8080"
+ name: envoy-gateway/gateway-1
+ protocol: TCP
+ http:
+ - address: 0.0.0.0
+ externalPort: 80
+ headers:
+ withUnderscoresAction: RejectRequest
+ host:
+ stripTrailingHostDot: true
+ hostnames:
+ - '*'
+ metadata:
+ kind: Gateway
+ name: gateway-1
+ namespace: envoy-gateway
+ sectionName: http-1
+ name: envoy-gateway/gateway-1/http-1
+ path:
+ escapedSlashesAction: UnescapeAndRedirect
+ mergeSlashes: true
+ port: 10080
+ readyListener:
+ address: 0.0.0.0
+ ipFamily: IPv4
+ path: /ready
+ port: 19003
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index d8ff851451..38269eb5e1 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -316,6 +316,8 @@ type HTTPListener struct {
GeoIPProvider *GeoIPProvider `json:"geoIPProvider,omitempty" yaml:"geoIPProvider,omitempty"`
// Path contains settings for path URI manipulations
Path PathSettings `json:"path,omitempty"`
+ // Host contains settings for Host/Authority header normalization
+ Host *HostSettings `json:"host,omitempty" yaml:"host,omitempty"`
// HTTP1 provides HTTP/1 configuration on the listener
// +optional
HTTP1 *HTTP1Settings `json:"http1,omitempty" yaml:"http1,omitempty"`
@@ -593,6 +595,13 @@ type PathSettings struct {
EscapedSlashesAction PathEscapedSlashAction `json:"escapedSlashesAction" yaml:"escapedSlashesAction"`
}
+// HostSettings holds configuration for Host/Authority header normalization
+// +k8s:deepcopy-gen=true
+type HostSettings struct {
+ // StripTrailingHostDot strips the trailing dot from the Host/Authority header before processing.
+ StripTrailingHostDot bool `json:"stripTrailingHostDot,omitempty" yaml:"stripTrailingHostDot,omitempty"`
+}
+
// ProxyProtocolSettings holds configuration for proxy protocol
// +k8s:deepcopy-gen=true
type ProxyProtocolSettings struct {
diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go
index f4e3a81b02..ecee140465 100644
--- a/internal/ir/zz_generated.deepcopy.go
+++ b/internal/ir/zz_generated.deepcopy.go
@@ -2327,6 +2327,11 @@ func (in *HTTPListener) DeepCopyInto(out *HTTPListener) {
(*in).DeepCopyInto(*out)
}
out.Path = in.Path
+ if in.Host != nil {
+ in, out := &in.Host, &out.Host
+ *out = new(HostSettings)
+ **out = **in
+ }
if in.HTTP1 != nil {
in, out := &in.HTTP1, &out.HTTP1
*out = new(HTTP1Settings)
@@ -2829,6 +2834,21 @@ func (in *HealthCheckSettings) DeepCopy() *HealthCheckSettings {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostSettings) DeepCopyInto(out *HostSettings) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostSettings.
+func (in *HostSettings) DeepCopy() *HostSettings {
+ if in == nil {
+ return nil
+ }
+ out := new(HostSettings)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Infra) DeepCopyInto(out *Infra) {
*out = *in
diff --git a/internal/xds/translator/listener.go b/internal/xds/translator/listener.go
index a68ce651d8..1f8569dfcb 100644
--- a/internal/xds/translator/listener.go
+++ b/internal/xds/translator/listener.go
@@ -401,6 +401,11 @@ func (t *Translator) addHCMToXDSListener(
RequestIdExtension: buildRequestIDExtension(irListener.RequestID),
}
+ // Normalize the Host/Authority header if configured.
+ if irListener.Host != nil {
+ mgr.StripTrailingHostDot = irListener.Host.StripTrailingHostDot
+ }
+
// Set the :scheme header to match the upstream transport protocol (http/https) if configured.
// This ensures the correct scheme is sent to backends using TLS when enabled.
if irListener.MatchBackendScheme {
diff --git a/internal/xds/translator/testdata/in/xds-ir/host-normalization.yaml b/internal/xds/translator/testdata/in/xds-ir/host-normalization.yaml
new file mode 100644
index 0000000000..49bb82173f
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/host-normalization.yaml
@@ -0,0 +1,33 @@
+http:
+ - name: "first-listener"
+ address: "::"
+ port: 8081
+ hostnames:
+ - "*"
+ routes:
+ - name: "first-route"
+ hostname: "*"
+ destination:
+ name: "first-route-dest"
+ settings:
+ - endpoints:
+ - host: "1.1.1.1"
+ port: 8081
+ name: "first-route-dest/backend/0"
+ host:
+ stripTrailingHostDot: true
+ - name: "second-listener"
+ address: "::"
+ port: 8082
+ hostnames:
+ - "*"
+ routes:
+ - name: "second-route"
+ hostname: "*"
+ destination:
+ name: "second-route-dest"
+ settings:
+ - endpoints:
+ - host: "2.2.2.2"
+ port: 8082
+ name: "second-route-dest/backend/0"
diff --git a/internal/xds/translator/testdata/out/xds-ir/host-normalization.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/host-normalization.clusters.yaml
new file mode 100644
index 0000000000..2597993e4b
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/host-normalization.clusters.yaml
@@ -0,0 +1,46 @@
+- circuitBreakers:
+ thresholds:
+ - maxRetries: 1024
+ commonLbConfig: {}
+ connectTimeout: 10s
+ dnsLookupFamily: V4_PREFERRED
+ edsClusterConfig:
+ edsConfig:
+ ads: {}
+ resourceApiVersion: V3
+ serviceName: first-route-dest
+ ignoreHealthOnHostRemoval: true
+ loadBalancingPolicy:
+ policies:
+ - typedExtensionConfig:
+ name: envoy.load_balancing_policies.least_request
+ typedConfig:
+ '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest
+ localityLbConfig:
+ localityWeightedLbConfig: {}
+ name: first-route-dest
+ perConnectionBufferLimitBytes: 32768
+ type: EDS
+- circuitBreakers:
+ thresholds:
+ - maxRetries: 1024
+ commonLbConfig: {}
+ connectTimeout: 10s
+ dnsLookupFamily: V4_PREFERRED
+ edsClusterConfig:
+ edsConfig:
+ ads: {}
+ resourceApiVersion: V3
+ serviceName: second-route-dest
+ ignoreHealthOnHostRemoval: true
+ loadBalancingPolicy:
+ policies:
+ - typedExtensionConfig:
+ name: envoy.load_balancing_policies.least_request
+ typedConfig:
+ '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest
+ localityLbConfig:
+ localityWeightedLbConfig: {}
+ name: second-route-dest
+ perConnectionBufferLimitBytes: 32768
+ type: EDS
diff --git a/internal/xds/translator/testdata/out/xds-ir/host-normalization.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/host-normalization.endpoints.yaml
new file mode 100644
index 0000000000..9ad09dd91b
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/host-normalization.endpoints.yaml
@@ -0,0 +1,24 @@
+- clusterName: first-route-dest
+ endpoints:
+ - lbEndpoints:
+ - endpoint:
+ address:
+ socketAddress:
+ address: 1.1.1.1
+ portValue: 8081
+ loadBalancingWeight: 1
+ loadBalancingWeight: 1
+ locality:
+ region: first-route-dest/backend/0
+- clusterName: second-route-dest
+ endpoints:
+ - lbEndpoints:
+ - endpoint:
+ address:
+ socketAddress:
+ address: 2.2.2.2
+ portValue: 8082
+ loadBalancingWeight: 1
+ loadBalancingWeight: 1
+ locality:
+ region: second-route-dest/backend/0
diff --git a/internal/xds/translator/testdata/out/xds-ir/host-normalization.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/host-normalization.listeners.yaml
new file mode 100644
index 0000000000..0e7f782798
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/host-normalization.listeners.yaml
@@ -0,0 +1,67 @@
+- address:
+ socketAddress:
+ address: '::'
+ portValue: 8081
+ defaultFilterChain:
+ filters:
+ - name: envoy.filters.network.http_connection_manager
+ typedConfig:
+ '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ commonHttpProtocolOptions:
+ headersWithUnderscoresAction: REJECT_REQUEST
+ http2ProtocolOptions:
+ initialConnectionWindowSize: 1048576
+ initialStreamWindowSize: 65536
+ maxConcurrentStreams: 100
+ httpFilters:
+ - name: envoy.filters.http.router
+ typedConfig:
+ '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+ suppressEnvoyHeaders: true
+ normalizePath: true
+ rds:
+ configSource:
+ ads: {}
+ resourceApiVersion: V3
+ routeConfigName: first-listener
+ serverHeaderTransformation: PASS_THROUGH
+ statPrefix: http-8081
+ stripTrailingHostDot: true
+ useRemoteAddress: true
+ name: first-listener
+ maxConnectionsToAcceptPerSocketEvent: 1
+ name: first-listener
+ perConnectionBufferLimitBytes: 32768
+- address:
+ socketAddress:
+ address: '::'
+ portValue: 8082
+ defaultFilterChain:
+ filters:
+ - name: envoy.filters.network.http_connection_manager
+ typedConfig:
+ '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ commonHttpProtocolOptions:
+ headersWithUnderscoresAction: REJECT_REQUEST
+ http2ProtocolOptions:
+ initialConnectionWindowSize: 1048576
+ initialStreamWindowSize: 65536
+ maxConcurrentStreams: 100
+ httpFilters:
+ - name: envoy.filters.http.router
+ typedConfig:
+ '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+ suppressEnvoyHeaders: true
+ normalizePath: true
+ rds:
+ configSource:
+ ads: {}
+ resourceApiVersion: V3
+ routeConfigName: second-listener
+ serverHeaderTransformation: PASS_THROUGH
+ statPrefix: http-8082
+ useRemoteAddress: true
+ name: second-listener
+ maxConnectionsToAcceptPerSocketEvent: 1
+ name: second-listener
+ perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/host-normalization.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/host-normalization.routes.yaml
new file mode 100644
index 0000000000..ff93cfff36
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/host-normalization.routes.yaml
@@ -0,0 +1,28 @@
+- ignorePortInHostMatching: true
+ name: first-listener
+ virtualHosts:
+ - domains:
+ - '*'
+ name: first-listener/*
+ routes:
+ - match:
+ prefix: /
+ name: first-route
+ route:
+ cluster: first-route-dest
+ upgradeConfigs:
+ - upgradeType: websocket
+- ignorePortInHostMatching: true
+ name: second-listener
+ virtualHosts:
+ - domains:
+ - '*'
+ name: second-listener/*
+ routes:
+ - match:
+ prefix: /
+ name: second-route
+ route:
+ cluster: second-route-dest
+ upgradeConfigs:
+ - upgradeType: websocket
diff --git a/release-notes/current/new_features/9115-host-normalization-clienttrafficpolicy.md b/release-notes/current/new_features/9115-host-normalization-clienttrafficpolicy.md
new file mode 100644
index 0000000000..e05b6bfa59
--- /dev/null
+++ b/release-notes/current/new_features/9115-host-normalization-clienttrafficpolicy.md
@@ -0,0 +1 @@
+Added a new `host` section under `ClientTrafficPolicy`'s `headers` with a `stripTrailingHostDot` field to normalize the Host/Authority header (trailing dot removal) without an EnvoyPatchPolicy.
diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md
index 7d78e634ab..ea29423c2e 100644
--- a/site/content/en/latest/api/extension_types.md
+++ b/site/content/en/latest/api/extension_types.md
@@ -3346,6 +3346,7 @@ _Appears in:_
| `requestID` | _[RequestIDAction](#requestidaction)_ | false | | RequestID configures Envoy's behavior for handling the `X-Request-ID` header.
When omitted default behavior is `Generate` which builds the `X-Request-ID` for every request
and ignores pre-existing values from the edge.
(An "edge request" refers to a request from an external client to the Envoy entrypoint.) |
| `earlyRequestHeaders` | _[HTTPHeaderFilter](#httpheaderfilter)_ | false | | EarlyRequestHeaders defines settings for early request header modification, before envoy performs
routing, tracing and built-in header manipulation. |
| `lateResponseHeaders` | _[HTTPHeaderFilter](#httpheaderfilter)_ | false | | LateResponseHeaders defines settings for global response header modification. |
+| `host` | _[HostSettings](#hostsettings)_ | false | | Host enables managing how the Host/Authority header set by clients can be normalized. |
#### HealthCheck
@@ -3394,6 +3395,21 @@ _Appears in:_
| `path` | _string_ | true | | Path specifies the HTTP path to match on for health check requests. |
+#### HostSettings
+
+
+
+HostSettings provides settings that manage how the incoming Host/Authority header
+set by clients is normalized.
+
+_Appears in:_
+- [HeaderSettings](#headersettings)
+
+| Field | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `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. |
+
+
#### IPEndpoint
diff --git a/site/content/en/latest/tasks/traffic/host-header-normalization.md b/site/content/en/latest/tasks/traffic/host-header-normalization.md
new file mode 100644
index 0000000000..e0a885a524
--- /dev/null
+++ b/site/content/en/latest/tasks/traffic/host-header-normalization.md
@@ -0,0 +1,78 @@
+---
+title: "Host Normalization"
+---
+
+This task explains how to configure host normalization settings
+using the [ClientTrafficPolicy][] API.
+
+## Prerequisites
+
+{{< boilerplate prerequisites >}}
+
+## Stripping Trailing Dot from the Host Header
+
+Envoy does not strip trailing dots from the `Host` header by default, unlike some
+other proxies (e.g. NGINX). This means requests with `Host: example.com.` will not
+match routes with domains set to `example.com`. Use `spec.headers.host.stripTrailingHostDot` to
+normalize these requests.
+
+When the host includes a port (e.g. `example.com.:443`), only the trailing dot from
+the host section is stripped, leaving the port as-is (`example.com:443`).
+
+{{< tabpane text=true >}}
+{{% tab header="Apply from stdin" %}}
+
+```shell
+cat <}}
+
+Verify the policy is accepted:
+
+```shell
+kubectl get clienttrafficpolicies.gateway.envoyproxy.io -n default
+```
+
+```
+NAME STATUS AGE
+strip-trailing-dot Accepted 5s
+```
+
+[ClientTrafficPolicy]: ../../../api/extension_types#clienttrafficpolicy
diff --git a/test/e2e/testdata/host-normalization.yaml b/test/e2e/testdata/host-normalization.yaml
new file mode 100644
index 0000000000..b88f904641
--- /dev/null
+++ b/test/e2e/testdata/host-normalization.yaml
@@ -0,0 +1,32 @@
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: ClientTrafficPolicy
+metadata:
+ name: strip-trailing-host-dot-ctp
+ namespace: gateway-conformance-infra
+spec:
+ targetRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: same-namespace
+ headers:
+ host:
+ stripTrailingHostDot: true
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: http-host-normalization
+ namespace: gateway-conformance-infra
+spec:
+ parentRefs:
+ - name: same-namespace
+ hostnames:
+ - host-normalization.example.com
+ rules:
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /host-normalization
+ backendRefs:
+ - name: infra-backend-v1
+ port: 8080
diff --git a/test/e2e/tests/host_normalization.go b/test/e2e/tests/host_normalization.go
new file mode 100644
index 0000000000..1259be7f00
--- /dev/null
+++ b/test/e2e/tests/host_normalization.go
@@ -0,0 +1,83 @@
+// Copyright Envoy Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+//go:build e2e
+
+package tests
+
+import (
+ "testing"
+
+ "k8s.io/apimachinery/pkg/types"
+ gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+ "sigs.k8s.io/gateway-api/conformance/utils/http"
+ "sigs.k8s.io/gateway-api/conformance/utils/kubernetes"
+ "sigs.k8s.io/gateway-api/conformance/utils/suite"
+
+ "github.com/envoyproxy/gateway/internal/gatewayapi"
+ "github.com/envoyproxy/gateway/internal/gatewayapi/resource"
+)
+
+func init() {
+ ConformanceTests = append(ConformanceTests, HostNormalizationTest)
+}
+
+var HostNormalizationTest = suite.ConformanceTest{
+ ShortName: "HostNormalization",
+ Description: "Strip the trailing dot from the Host header before route matching",
+ Manifests: []string{"testdata/host-normalization.yaml"},
+ Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
+ ns := "gateway-conformance-infra"
+ routeNN := types.NamespacedName{Name: "http-host-normalization", Namespace: ns}
+ gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns}
+ gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, routeNN)
+
+ ancestorRef := gwapiv1.ParentReference{
+ Group: gatewayapi.GroupPtr(gwapiv1.GroupName),
+ Kind: gatewayapi.KindPtr(resource.KindGateway),
+ Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+ Name: gwapiv1.ObjectName(gwNN.Name),
+ }
+ ClientTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "strip-trailing-host-dot-ctp", Namespace: ns}, suite.ControllerName, ancestorRef)
+
+ t.Run("host without trailing dot should match the route", func(t *testing.T) {
+ expected := http.ExpectedResponse{
+ Request: http.Request{
+ Host: "host-normalization.example.com",
+ Path: "/host-normalization",
+ },
+ Response: http.Response{
+ StatusCodes: []int{200},
+ },
+ Namespace: ns,
+ }
+
+ http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expected)
+ })
+
+ t.Run("host with trailing dot should be normalized and match the route", func(t *testing.T) {
+ expected := http.ExpectedResponse{
+ Request: http.Request{
+ Host: "host-normalization.example.com.",
+ Path: "/host-normalization",
+ },
+ // The trailing dot is stripped before routing, so the backend
+ // receives the normalized Host header.
+ ExpectedRequest: &http.ExpectedRequest{
+ Request: http.Request{
+ Host: "host-normalization.example.com",
+ Path: "/host-normalization",
+ },
+ },
+ Response: http.Response{
+ StatusCodes: []int{200},
+ },
+ Namespace: ns,
+ }
+
+ http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expected)
+ })
+ },
+}
diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml
index f0fcbd76a5..55beeb684e 100644
--- a/test/helm/gateway-crds-helm/all.out.yaml
+++ b/test/helm/gateway-crds-helm/all.out.yaml
@@ -28816,6 +28816,21 @@ spec:
EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
and responses.
type: boolean
+ host:
+ description: Host enables managing how the Host/Authority header
+ set by clients can be normalized.
+ properties:
+ stripTrailingHostDot:
+ description: |-
+ 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.
+ type: boolean
+ type: object
lateResponseHeaders:
description: LateResponseHeaders defines settings for global response
header modification.
diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml
index a91c9b9ca8..fffd7340e1 100644
--- a/test/helm/gateway-crds-helm/e2e.out.yaml
+++ b/test/helm/gateway-crds-helm/e2e.out.yaml
@@ -4754,6 +4754,21 @@ spec:
EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
and responses.
type: boolean
+ host:
+ description: Host enables managing how the Host/Authority header
+ set by clients can be normalized.
+ properties:
+ stripTrailingHostDot:
+ description: |-
+ 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.
+ type: boolean
+ type: object
lateResponseHeaders:
description: LateResponseHeaders defines settings for global response
header modification.
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 f926f93ec0..1d7dff35e1 100644
--- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
+++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
@@ -4754,6 +4754,21 @@ spec:
EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
and responses.
type: boolean
+ host:
+ description: Host enables managing how the Host/Authority header
+ set by clients can be normalized.
+ properties:
+ stripTrailingHostDot:
+ description: |-
+ 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.
+ type: boolean
+ type: object
lateResponseHeaders:
description: LateResponseHeaders defines settings for global response
header modification.