diff --git a/api/v1alpha1/clienttrafficpolicy_types.go b/api/v1alpha1/clienttrafficpolicy_types.go index 347eb946353..63b2c91fb2e 100644 --- a/api/v1alpha1/clienttrafficpolicy_types.go +++ b/api/v1alpha1/clienttrafficpolicy_types.go @@ -7,6 +7,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" ) @@ -134,6 +135,12 @@ type HeaderSettings struct { // // +optional PreserveXRequestID *bool `json:"preserveXRequestID,omitempty"` + + // EarlyRequestHeaders defines settings for early request header modification, before envoy performs + // routing, tracing and built-in header manipulation. + // + // +optional + EarlyRequestHeaders *gwapiv1.HTTPHeaderFilter `json:"earlyRequestHeaders,omitempty"` } // WithUnderscoresAction configures the action to take when an HTTP header with underscores diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 79c8e98a525..62fa950a3e8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2714,6 +2714,11 @@ func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) { *out = new(bool) **out = **in } + if in.EarlyRequestHeaders != nil { + in, out := &in.EarlyRequestHeaders, &out.EarlyRequestHeaders + *out = new(apisv1.HTTPHeaderFilter) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderSettings. diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml index 0dd5ac6a980..5483ff78e64 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml @@ -159,6 +159,148 @@ spec: DisableRateLimitHeaders configures Envoy Proxy to omit the "X-RateLimit-" response headers when rate limiting is enabled. type: boolean + earlyRequestHeaders: + description: |- + EarlyRequestHeaders defines settings for early request header modification, before envoy performs + routing, tracing and built-in header manipulation. + properties: + add: + description: |- + Add adds the given header(s) (name, value) to the request + before the action. It appends to any existing values associated + with the header name. + + + Input: + GET /foo HTTP/1.1 + my-header: foo + + + Config: + add: + - name: "my-header" + value: "bar,baz" + + + Output: + GET /foo HTTP/1.1 + my-header: foo,bar,baz + items: + description: HTTPHeader represents an HTTP Header name and + value as defined by RFC 7230. + properties: + name: + description: |- + Name is the name of the HTTP Header to be matched. Name matching MUST be + case insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). + + + If multiple entries specify equivalent header names, the first entry with + an equivalent name MUST be considered for a match. Subsequent entries + with an equivalent header name MUST be ignored. Due to the + case-insensitivity of header names, "foo" and "Foo" are considered + equivalent. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value is the value of HTTP Header to be + matched. + maxLength: 4096 + minLength: 1 + type: string + required: + - name + - value + type: object + maxItems: 16 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + remove: + description: |- + Remove the given header(s) from the HTTP request before the action. The + value of Remove is a list of HTTP header names. Note that the header + names are case-insensitive (see + https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). + + + Input: + GET /foo HTTP/1.1 + my-header1: foo + my-header2: bar + my-header3: baz + + + Config: + remove: ["my-header1", "my-header3"] + + + Output: + GET /foo HTTP/1.1 + my-header2: bar + items: + type: string + maxItems: 16 + type: array + x-kubernetes-list-type: set + set: + description: |- + Set overwrites the request with the given header (name, value) + before the action. + + + Input: + GET /foo HTTP/1.1 + my-header: foo + + + Config: + set: + - name: "my-header" + value: "bar" + + + Output: + GET /foo HTTP/1.1 + my-header: bar + items: + description: HTTPHeader represents an HTTP Header name and + value as defined by RFC 7230. + properties: + name: + description: |- + Name is the name of the HTTP Header to be matched. Name matching MUST be + case insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). + + + If multiple entries specify equivalent header names, the first entry with + an equivalent name MUST be considered for a match. Subsequent entries + with an equivalent header name MUST be ignored. Due to the + case-insensitivity of header names, "foo" and "Foo" are considered + equivalent. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value is the value of HTTP Header to be + matched. + maxLength: 4096 + minLength: 1 + type: string + required: + - name + - value + type: object + maxItems: 16 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object enableEnvoyHeaders: description: |- EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests diff --git a/internal/gatewayapi/clienttrafficpolicy.go b/internal/gatewayapi/clienttrafficpolicy.go index c1877ffa653..44d813c255c 100644 --- a/internal/gatewayapi/clienttrafficpolicy.go +++ b/internal/gatewayapi/clienttrafficpolicy.go @@ -18,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/utils/ptr" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" @@ -432,7 +433,10 @@ func (t *Translator) translateClientTrafficPolicyForListener(policy *egv1a1.Clie translateClientIPDetection(policy.Spec.ClientIPDetection, httpIR) // Translate Header Settings - translateListenerHeaderSettings(policy.Spec.Headers, httpIR) + if err = translateListenerHeaderSettings(policy.Spec.Headers, httpIR); err != nil { + err = perr.WithMessage(err, "Headers") + errs = errors.Join(errs, err) + } // Translate Path Settings translatePathSettings(policy.Spec.Path, httpIR) @@ -613,9 +617,9 @@ func translateClientIPDetection(clientIPDetection *egv1a1.ClientIPDetectionSetti httpIR.ClientIPDetection = (*ir.ClientIPDetectionSettings)(clientIPDetection) } -func translateListenerHeaderSettings(headerSettings *egv1a1.HeaderSettings, httpIR *ir.HTTPListener) { +func translateListenerHeaderSettings(headerSettings *egv1a1.HeaderSettings, httpIR *ir.HTTPListener) error { if headerSettings == nil { - return + return nil } httpIR.Headers = &ir.HeaderSettings{ EnableEnvoyHeaders: ptr.Deref(headerSettings.EnableEnvoyHeaders, false), @@ -634,6 +638,16 @@ func translateListenerHeaderSettings(headerSettings *egv1a1.HeaderSettings, http httpIR.Headers.XForwardedClientCert.CertDetailsToAdd = headerSettings.XForwardedClientCert.CertDetailsToAdd } } + + if headerSettings.EarlyRequestHeaders != nil { + headersToAdd, headersToRemove, err := translateEarlyRequestHeaders(headerSettings.EarlyRequestHeaders) + if err != nil { + return err + } + httpIR.Headers.EarlyAddRequestHeaders = headersToAdd + httpIR.Headers.EarlyRemoveRequestHeaders = headersToRemove + } + return nil } func translateHTTP1Settings(http1Settings *egv1a1.HTTP1Settings, httpIR *ir.HTTPListener) error { @@ -869,3 +883,130 @@ func buildConnection(connection *egv1a1.ClientConnection) (*ir.ClientConnection, return irConnection, nil } + +func translateEarlyRequestHeaders(headerModifier *gwapiv1.HTTPHeaderFilter) ([]ir.AddHeader, []string, error) { + // Make sure the header modifier config actually exists + if headerModifier == nil { + return nil, nil, nil + } + var errs error + emptyFilterConfig := true // keep track of whether the provided config is empty or not + + var AddRequestHeaders []ir.AddHeader + var RemoveRequestHeaders []string + + // Add request headers + if headersToAdd := headerModifier.Add; headersToAdd != nil { + if len(headersToAdd) > 0 { + emptyFilterConfig = false + } + for _, addHeader := range headersToAdd { + emptyFilterConfig = false + if addHeader.Name == "" { + errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot add a header with an empty name")) + // try to process the rest of the headers and produce a valid config. + continue + } + // Per Gateway API specification on HTTPHeaderName, : and / are invalid characters in header names + if strings.ContainsAny(string(addHeader.Name), "/:") { + errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders Filter cannot set headers with a '/' or ':' character in them. Header: %q", string(addHeader.Name))) + continue + } + // Check if the header is a duplicate + headerKey := string(addHeader.Name) + canAddHeader := true + for _, h := range AddRequestHeaders { + if strings.EqualFold(h.Name, headerKey) { + canAddHeader = false + break + } + } + + if !canAddHeader { + continue + } + + newHeader := ir.AddHeader{ + Name: headerKey, + Append: true, + Value: strings.Split(addHeader.Value, ","), + } + + AddRequestHeaders = append(AddRequestHeaders, newHeader) + } + } + + // Set headers + if headersToSet := headerModifier.Set; headersToSet != nil { + if len(headersToSet) > 0 { + emptyFilterConfig = false + } + for _, setHeader := range headersToSet { + + if setHeader.Name == "" { + errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot set a header with an empty name")) + continue + } + // Per Gateway API specification on HTTPHeaderName, : and / are invalid characters in header names + if strings.ContainsAny(string(setHeader.Name), "/:") { + errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot set headers with a '/' or ':' character in them. Header: '%s'", string(setHeader.Name))) + continue + } + + // Check if the header to be set has already been configured + headerKey := string(setHeader.Name) + canAddHeader := true + for _, h := range AddRequestHeaders { + if strings.EqualFold(h.Name, headerKey) { + canAddHeader = false + break + } + } + if !canAddHeader { + continue + } + newHeader := ir.AddHeader{ + Name: string(setHeader.Name), + Append: false, + Value: strings.Split(setHeader.Value, ","), + } + + AddRequestHeaders = append(AddRequestHeaders, newHeader) + } + } + + // Remove request headers + // As far as Envoy is concerned, it is ok to configure a header to be added/set and also in the list of + // headers to remove. It will remove the original header if present and then add/set the header after. + if headersToRemove := headerModifier.Remove; headersToRemove != nil { + if len(headersToRemove) > 0 { + emptyFilterConfig = false + } + for _, removedHeader := range headersToRemove { + if removedHeader == "" { + errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot remove a header with an empty name")) + continue + } + + canRemHeader := true + for _, h := range RemoveRequestHeaders { + if strings.EqualFold(h, removedHeader) { + canRemHeader = false + break + } + } + if !canRemHeader { + continue + } + + RemoveRequestHeaders = append(RemoveRequestHeaders, removedHeader) + } + } + + // Update the status if the filter failed to configure any valid headers to add/remove + if len(AddRequestHeaders) == 0 && len(RemoveRequestHeaders) == 0 && !emptyFilterConfig { + errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders did not provide valid configuration to add/set/remove any headers")) + } + + return AddRequestHeaders, RemoveRequestHeaders, errs +} diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml new file mode 100644 index 00000000000..3b2331bba7f --- /dev/null +++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml @@ -0,0 +1,43 @@ +clientTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: ClientTrafficPolicy + metadata: + namespace: envoy-gateway + name: target-gateway-1 + spec: + headers: + enableEnvoyHeaders: true + withUnderscoresAction: Allow + preserveXRequestID: true + earlyRequestHeaders: + add: + - name: "" + value: "empty" + - name: "invalid" + value: ":/" + set: + - name: "" + value: "empty" + - name: "invalid" + value: ":/" + remove: + - "" + 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-headers-error.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.out.yaml new file mode 100644 index 00000000000..9eee58d7df7 --- /dev/null +++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.out.yaml @@ -0,0 +1,127 @@ +clientTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: ClientTrafficPolicy + metadata: + creationTimestamp: null + name: target-gateway-1 + namespace: envoy-gateway + spec: + headers: + earlyRequestHeaders: + add: + - name: "" + value: empty + - name: invalid + value: :/ + remove: + - "" + set: + - name: "" + value: empty + - name: invalid + value: :/ + enableEnvoyHeaders: true + preserveXRequestID: true + withUnderscoresAction: Allow + 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: |- + Headers: EarlyRequestHeaders cannot add a header with an empty name + EarlyRequestHeaders cannot set a header with an empty name + EarlyRequestHeaders cannot remove a header with an empty name. + reason: Invalid + status: "False" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + 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: + - address: null + 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 + name: envoy-gateway/gateway-1 +xdsIR: + envoy-gateway/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + headers: + enableEnvoyHeaders: true + preserveXRequestID: true + withUnderscoresAction: Allow + hostnames: + - '*' + isHTTP2: false + 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 diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml index 6d73bee1a16..3234aed7da8 100644 --- a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml +++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml @@ -9,6 +9,20 @@ clientTrafficPolicies: enableEnvoyHeaders: true withUnderscoresAction: Allow preserveXRequestID: true + earlyRequestHeaders: + add: + - name: "my-added-header" + value: "my-added-header-value" + - name: "my-added-header" + value: "my-added-header-value" + set: + - name: "my-set-header" + value: "my-set-header-value" + - name: "my-set-header" + value: "my-set-header-value" + remove: + - "my-removed-header" + - "my-removed-header" targetRef: group: gateway.networking.k8s.io kind: Gateway diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.out.yaml index 8b32bb192da..4e66bd91c64 100644 --- a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.out.yaml +++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.out.yaml @@ -7,6 +7,20 @@ clientTrafficPolicies: namespace: envoy-gateway spec: headers: + earlyRequestHeaders: + add: + - name: my-added-header + value: my-added-header-value + - name: my-added-header + value: my-added-header-value + remove: + - my-removed-header + - my-removed-header + set: + - name: my-set-header + value: my-set-header-value + - name: my-set-header + value: my-set-header-value enableEnvoyHeaders: true preserveXRequestID: true withUnderscoresAction: Allow @@ -129,6 +143,17 @@ xdsIR: http: - address: 0.0.0.0 headers: + earlyAddRequestHeaders: + - append: true + name: my-added-header + value: + - my-added-header-value + - append: false + name: my-set-header + value: + - my-set-header-value + earlyRemoveRequestHeaders: + - my-removed-header enableEnvoyHeaders: true preserveXRequestID: true withUnderscoresAction: Allow @@ -147,6 +172,17 @@ xdsIR: port: 10080 - address: 0.0.0.0 headers: + earlyAddRequestHeaders: + - append: true + name: my-added-header + value: + - my-added-header-value + - append: false + name: my-set-header + value: + - my-set-header-value + earlyRemoveRequestHeaders: + - my-removed-header enableEnvoyHeaders: true preserveXRequestID: true withUnderscoresAction: Allow diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 56c1bef0958..821d4bd1c70 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -494,6 +494,12 @@ type HeaderSettings struct { // (Edge request is the request from external clients to front Envoy) and not reset it, which is the current Envoy behaviour. // It defaults to false. PreserveXRequestID bool `json:"preserveXRequestID,omitempty" yaml:"preserveXRequestID,omitempty"` + + // EarlyAddRequestHeaders defines headers that would be added before envoy request processing. + EarlyAddRequestHeaders []AddHeader `json:"earlyAddRequestHeaders,omitempty" yaml:"earlyAddRequestHeaders,omitempty"` + + // EarlyRemoveRequestHeaders defines headers that would be removed before envoy request processing. + EarlyRemoveRequestHeaders []string `json:"earlyRemoveRequestHeaders,omitempty" yaml:"earlyRemoveRequestHeaders,omitempty"` } // ClientTimeout sets the timeout configuration for downstream connections diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 5f8f879e6a0..d38cd3b825c 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1481,6 +1481,18 @@ func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) { *out = new(XForwardedClientCert) (*in).DeepCopyInto(*out) } + if in.EarlyAddRequestHeaders != nil { + in, out := &in.EarlyAddRequestHeaders, &out.EarlyAddRequestHeaders + *out = make([]AddHeader, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.EarlyRemoveRequestHeaders != nil { + in, out := &in.EarlyRemoveRequestHeaders, &out.EarlyRemoveRequestHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderSettings. diff --git a/internal/xds/translator/listener.go b/internal/xds/translator/listener.go index 0812010ade1..9b442c75105 100644 --- a/internal/xds/translator/listener.go +++ b/internal/xds/translator/listener.go @@ -12,6 +12,7 @@ import ( xdscore "github.com/cncf/xds/go/xds/core/v3" matcher "github.com/cncf/xds/go/xds/type/matcher/v3" + mutation_rulesv3 "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" tls_inspectorv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/listener/tls_inspector/v3" @@ -19,6 +20,7 @@ import ( hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" tcpv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/tcp_proxy/v3" udpv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/udp/udp_proxy/v3" + early_header_mutationv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/early_header_mutation/header_mutation/v3" preservecasev3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/header_formatters/preserve_case/v3" customheaderv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/original_ip_detection/custom_header/v3" quicv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/quic/v3" @@ -274,9 +276,10 @@ func (t *Translator) addHCMToXDSListener(xdsListener *listenerv3.Listener, irLis CommonHttpProtocolOptions: &corev3.HttpProtocolOptions{ HeadersWithUnderscoresAction: buildHeadersWithUnderscoresAction(irListener.Headers), }, - Tracing: hcmTracing, - ForwardClientCertDetails: buildForwardClientCertDetailsAction(irListener.Headers), - PreserveExternalRequestId: ptr.Deref(irListener.Headers, ir.HeaderSettings{}).PreserveXRequestID, + Tracing: hcmTracing, + ForwardClientCertDetails: buildForwardClientCertDetailsAction(irListener.Headers), + PreserveExternalRequestId: ptr.Deref(irListener.Headers, ir.HeaderSettings{}).PreserveXRequestID, + EarlyHeaderMutationExtensions: buildEarlyHeaderMutation(irListener.Headers), } if mgr.ForwardClientCertDetails == hcmv3.HttpConnectionManager_APPEND_FORWARD || mgr.ForwardClientCertDetails == hcmv3.HttpConnectionManager_SANITIZE_SET { @@ -365,6 +368,73 @@ func (t *Translator) addHCMToXDSListener(xdsListener *listenerv3.Listener, irLis return nil } +func buildEarlyHeaderMutation(headers *ir.HeaderSettings) []*corev3.TypedExtensionConfig { + if headers == nil || (len(headers.EarlyAddRequestHeaders) == 0 && len(headers.EarlyRemoveRequestHeaders) == 0) { + return nil + } + + var mutationRules []*mutation_rulesv3.HeaderMutation + + for _, header := range headers.EarlyAddRequestHeaders { + var appendAction corev3.HeaderValueOption_HeaderAppendAction + if header.Append { + appendAction = corev3.HeaderValueOption_APPEND_IF_EXISTS_OR_ADD + } else { + appendAction = corev3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD + } + // Allow empty headers to be set, but don't add the config to do so unless necessary + if len(header.Value) == 0 { + mutationRules = append(mutationRules, &mutation_rulesv3.HeaderMutation{ + Action: &mutation_rulesv3.HeaderMutation_Append{ + Append: &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: header.Name, + }, + AppendAction: appendAction, + KeepEmptyValue: true, + }, + }, + }) + } else { + for _, val := range header.Value { + mutationRules = append(mutationRules, &mutation_rulesv3.HeaderMutation{ + Action: &mutation_rulesv3.HeaderMutation_Append{ + Append: &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: header.Name, + Value: val, + }, + AppendAction: appendAction, + KeepEmptyValue: val == "", + }, + }, + }) + } + } + } + + for _, header := range headers.EarlyRemoveRequestHeaders { + mr := &mutation_rulesv3.HeaderMutation{ + Action: &mutation_rulesv3.HeaderMutation_Remove{ + Remove: header, + }, + } + + mutationRules = append(mutationRules, mr) + } + + earlyHeaderMutationAny, _ := anypb.New(&early_header_mutationv3.HeaderMutation{ + Mutations: mutationRules, + }) + + return []*corev3.TypedExtensionConfig{ + { + Name: "envoy.http.early_header_mutation.header_mutation", + TypedConfig: earlyHeaderMutationAny, + }, + } +} + func addServerNamesMatch(xdsListener *listenerv3.Listener, filterChain *listenerv3.FilterChain, hostnames []string) error { // Dont add a filter chain match if the hostname is a wildcard character. if len(hostnames) > 0 && hostnames[0] != "*" { diff --git a/internal/xds/translator/testdata/in/xds-ir/http-early-header-mutation.yaml b/internal/xds/translator/testdata/in/xds-ir/http-early-header-mutation.yaml new file mode 100644 index 00000000000..6301153cd1c --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/http-early-header-mutation.yaml @@ -0,0 +1,59 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + http1: + preserveHeaderCase: true + path: + mergeSlashes: true + escapedSlashesAction: UnescapeAndRedirect + routes: + - name: "first-route" + hostname: "*" + destination: + name: "first-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 +- name: "second-listener" + address: "0.0.0.0" + port: 10081 + hostnames: + - "*" + headers: + earlyAddRequestHeaders: + - name: "some-header" + value: + - "some-value1" + - "some-value2" + append: true + - name: "some-header-2" + value: + - "some-value" + append: true + - name: "some-header3" + value: + - "some-value" + append: false + - name: "some-header4" + value: + - "some-value" + append: false + - name: "empty-header" + value: + append: false + earlyRemoveRequestHeaders: + - "some-header5" + - "some-header6" + routes: + - name: "second-route" + hostname: "*" + destination: + name: "second-route-dest" + settings: + - endpoints: + - host: "1.2.3.5" + port: 50000 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.clusters.yaml new file mode 100644 index 00000000000..22e6727066a --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.clusters.yaml @@ -0,0 +1,44 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: first-route-dest + lbPolicy: LEAST_REQUEST + name: first-route-dest + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS + typedExtensionProtocolOptions: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicitHttpConfig: + httpProtocolOptions: + headerKeyFormat: + statefulFormatter: + name: preserve_case + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.header_formatters.preserve_case.v3.PreserveCaseFormatterConfig +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: second-route-dest + lbPolicy: LEAST_REQUEST + name: second-route-dest + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.endpoints.yaml new file mode 100644 index 00000000000..28a57caf3b5 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.endpoints.yaml @@ -0,0 +1,24 @@ +- clusterName: first-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: first-route-dest/backend/0 +- clusterName: second-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.5 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: second-route-dest/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.listeners.yaml new file mode 100644 index 00000000000..69c2612a5f8 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.listeners.yaml @@ -0,0 +1,108 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + httpProtocolOptions: + headerKeyFormat: + statefulFormatter: + name: preserve_case + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.header_formatters.preserve_case.v3.PreserveCaseFormatterConfig + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: first-listener + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: first-listener + drainType: MODIFY_ONLY + name: first-listener + perConnectionBufferLimitBytes: 32768 +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10081 + 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 + earlyHeaderMutationExtensions: + - name: envoy.http.early_header_mutation.header_mutation + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.early_header_mutation.header_mutation.v3.HeaderMutation + mutations: + - append: + header: + key: some-header + value: some-value1 + - append: + header: + key: some-header + value: some-value2 + - append: + header: + key: some-header-2 + value: some-value + - append: + appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: some-header3 + value: some-value + - append: + appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: some-header4 + value: some-value + - append: + appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: empty-header + keepEmptyValue: true + - remove: some-header5 + - remove: some-header6 + 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-10081 + useRemoteAddress: true + name: second-listener + drainType: MODIFY_ONLY + name: second-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.routes.yaml new file mode 100644 index 00000000000..ff93cfff360 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-early-header-mutation.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/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 99caabc363e..3e6f75d074a 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1962,6 +1962,7 @@ _Appears in:_ | `xForwardedClientCert` | _[XForwardedClientCert](#xforwardedclientcert)_ | false | XForwardedClientCert configures how Envoy Proxy handle the x-forwarded-client-cert (XFCC) HTTP header.

x-forwarded-client-cert (XFCC) is an HTTP header used to forward the certificate
information of part or all of the clients or proxies that a request has flowed through,
on its way from the client to the server.

Envoy proxy may choose to sanitize/append/forward the XFCC header before proxying the request.

If not set, the default behavior is sanitizing the XFCC header. | | `withUnderscoresAction` | _[WithUnderscoresAction](#withunderscoresaction)_ | false | WithUnderscoresAction configures the action to take when an HTTP header with underscores
is encountered. The default action is to reject the request. | | `preserveXRequestID` | _boolean_ | false | PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge
(Edge request is the request from external clients to front Envoy) and not reset it, which is the current Envoy behaviour.
It defaults to false. | +| `earlyRequestHeaders` | _[HTTPHeaderFilter](#httpheaderfilter)_ | false | EarlyRequestHeaders defines settings for early request header modification, before envoy performs
routing, tracing and built-in header manipulation. | diff --git a/site/content/en/latest/tasks/traffic/http-request-headers.md b/site/content/en/latest/tasks/traffic/http-request-headers.md index 9cd60281cdf..5b73bfaf8d3 100644 --- a/site/content/en/latest/tasks/traffic/http-request-headers.md +++ b/site/content/en/latest/tasks/traffic/http-request-headers.md @@ -442,7 +442,179 @@ spec: {{% /tab %}} {{< /tabpane >}} +## Early Header Modification + +In some cases, it could be necessary to modify headers before the proxy performs any sort of processing, routing or tracing. Envoy Gateway supports this functionality using the [ClientTrafficPolicy][] API. + +A ClientTrafficPolicy resource can be attached to a Gateway resource to configure early header modifications for all its routes. In the following example we will demonstrate how early header modification can be configured. + +{{< tabpane text=true >}} +{{% tab header="Apply from stdin" %}} + +```shell +cat <}} + + +Querying `headers.example/get` should result in a `200` response from the example Gateway and the output from the +example app should indicate that the upstream example app received the following headers: +- `early-added-header` contains early (ClientTrafficPolicy) and late (RouteFilter) values +- `early-set-header` contains only early (ClientTrafficPolicy) and late (RouteFilter) values, since the early modification overwritten the client value. +- `early-removed-header` contains only the late (RouteFilter) value, since the early modification deleted the client value. + +```console +$ curl -vvv --header "Host: headers.example" "http://${GATEWAY_HOST}/get" --header "early-added-header: client" --header "early-set-header: client" --header "early-removed-header: client" +... +> GET /get HTTP/1.1 +> Host: headers.example +> User-Agent: curl/7.81.0 +> Accept: */* +> add-header: something +> +* Mark bundle as not supporting multiuse +< HTTP/1.1 200 OK +< content-type: application/json +< x-content-type-options: nosniff +< content-length: 474 +< x-envoy-upstream-service-time: 0 +< server: envoy +< + + "headers": { + "Accept": [ + "*/*" + ], + "Early-Added-Header": [ + "client", + "early", + "late" + ], + "Early-Set-Header": [ + "early", + "late" + ], + "Early-removed-Header": [ + "late" + ] +... +``` + [HTTPRoute]: https://gateway-api.sigs.k8s.io/api-types/httproute/ [HTTPRoute filters]: https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1.HTTPRouteFilter [Gateway API documentation]: https://gateway-api.sigs.k8s.io/ [req_filter]: https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1.HTTPHeaderFilter +[ClientTrafficPolicy]: ../../../api/extension_types#clienttrafficpolicy diff --git a/site/content/zh/latest/api/extension_types.md b/site/content/zh/latest/api/extension_types.md index 99caabc363e..3e6f75d074a 100644 --- a/site/content/zh/latest/api/extension_types.md +++ b/site/content/zh/latest/api/extension_types.md @@ -1962,6 +1962,7 @@ _Appears in:_ | `xForwardedClientCert` | _[XForwardedClientCert](#xforwardedclientcert)_ | false | XForwardedClientCert configures how Envoy Proxy handle the x-forwarded-client-cert (XFCC) HTTP header.

x-forwarded-client-cert (XFCC) is an HTTP header used to forward the certificate
information of part or all of the clients or proxies that a request has flowed through,
on its way from the client to the server.

Envoy proxy may choose to sanitize/append/forward the XFCC header before proxying the request.

If not set, the default behavior is sanitizing the XFCC header. | | `withUnderscoresAction` | _[WithUnderscoresAction](#withunderscoresaction)_ | false | WithUnderscoresAction configures the action to take when an HTTP header with underscores
is encountered. The default action is to reject the request. | | `preserveXRequestID` | _boolean_ | false | PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge
(Edge request is the request from external clients to front Envoy) and not reset it, which is the current Envoy behaviour.
It defaults to false. | +| `earlyRequestHeaders` | _[HTTPHeaderFilter](#httpheaderfilter)_ | false | EarlyRequestHeaders defines settings for early request header modification, before envoy performs
routing, tracing and built-in header manipulation. | diff --git a/test/e2e/testdata/header-settings.yaml b/test/e2e/testdata/header-settings.yaml new file mode 100644 index 00000000000..dab686f29c7 --- /dev/null +++ b/test/e2e/testdata/header-settings.yaml @@ -0,0 +1,47 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: ClientTrafficPolicy +metadata: + name: early-header-modifier-ctp + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: same-namespace + headers: + earlyRequestHeaders: + add: + - name: "early-added-header" + value: "early" + set: + - name: "early-set-header" + value: "early" + remove: + - "early-removed-header" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-with-early-headers + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /early-header + filters: + - type: RequestHeaderModifier + requestHeaderModifier: + add: + - name: early-added-header + value: late + - name: early-set-header + value: late + - name: early-removed-header + value: late + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/test/e2e/tests/header_settings.go b/test/e2e/tests/header_settings.go new file mode 100644 index 00000000000..32f0d731089 --- /dev/null +++ b/test/e2e/tests/header_settings.go @@ -0,0 +1,75 @@ +// 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 +// +build e2e + +package tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "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" +) + +func init() { + ConformanceTests = append(ConformanceTests, HeaderSettingsTest) +} + +var HeaderSettingsTest = suite.ConformanceTest{ + ShortName: "HeaderSettings", + Description: "Modify headers before regular processing", + Manifests: []string{"testdata/header-settings.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("Early header modifications should apply", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-early-headers", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + ancestorRef := gwapiv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(gatewayapi.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + } + ClientTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "early-header-modifier-ctp", Namespace: ns}, suite.ControllerName, ancestorRef) + + expected := http.ExpectedResponse{ + Request: http.Request{ + Path: "/early-header", + Headers: map[string]string{ + "early-added-header": "client", + "early-set-header": "client", + "early-removed-header": "client", + }, + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/early-header", + Headers: map[string]string{ + "early-added-header": "client,early,late", // client, early and late are all added to header + "early-set-header": "early,late", // early set overwrites client value + "early-removed-header": "late", // removed by early, so only late value exists + }, + }, + }, + Response: http.Response{ + StatusCode: 200, + }, + Namespace: ns, + } + + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expected) + }) + }, +}