diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index d33f6544d3..ae09dacde7 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -819,11 +819,33 @@ type ResponseOverride struct { } // CustomResponseMatch defines the configuration for matching a user response to return a custom one. +// When both statusCodes and responseHeaders are specified, both must match. +// +kubebuilder:validation:XValidation:rule="has(self.statusCodes) || has(self.responseHeaders)",message="at least one of statusCodes or responseHeaders must be specified" type CustomResponseMatch struct { // Status code to match on. The match evaluates to true if any of the matches are successful. + // + // +optional // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=50 - StatusCodes []StatusCodeMatch `json:"statusCodes"` + StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` + + // Response headers to match on. The match evaluates to true if all matches are successful. + // + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + ResponseHeaders []ResponseOverrideHeaderMatch `json:"responseHeaders,omitempty"` +} + +// ResponseOverrideHeaderMatch defines the configuration for matching a response header. +type ResponseOverrideHeaderMatch struct { + // Name of the HTTP header. + // The header name is case-insensitive. + // For example, "Foo" and "foo" are considered the same header. + Name gwapiv1.HTTPHeaderName `json:"name"` + + // Value within the HTTP header to match against. + Value StringMatch `json:"value"` } // StatusCodeValueType defines the types of values for the status code match supported by Envoy Gateway. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 7730ebed2b..2ce3ccb647 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2051,6 +2051,13 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ResponseHeaders != nil { + in, out := &in.ResponseHeaders, &out.ResponseHeaders + *out = make([]ResponseOverrideHeaderMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponseMatch. @@ -7938,6 +7945,22 @@ func (in *ResponseOverride) DeepCopy() *ResponseOverride { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverrideHeaderMatch) DeepCopyInto(out *ResponseOverrideHeaderMatch) { + *out = *in + in.Value.DeepCopyInto(&out.Value) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideHeaderMatch. +func (in *ResponseOverrideHeaderMatch) DeepCopy() *ResponseOverrideHeaderMatch { + if in == nil { + return nil + } + out := new(ResponseOverrideHeaderMatch) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Retry) DeepCopyInto(out *Retry) { *out = *in diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 2f2653c307..a407377cdc 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2382,6 +2382,52 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if all matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2437,9 +2483,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 46e1d1ea12..616745119d 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2381,6 +2381,52 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if all matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2436,9 +2482,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index 4f2378c5ce..c5d66e6f99 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -2382,6 +2382,10 @@ func (t *Translator) buildResponseOverride(policy *egv1a1.BackendTrafficPolicy, } } + for _, h := range ro.Match.ResponseHeaders { + match.ResponseHeaders = append(match.ResponseHeaders, *irStringMatch(string(h.Name), h.Value)) + } + if ro.Redirect != nil { redirect := &ir.Redirect{ Scheme: ro.Redirect.Scheme, diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml index 0e0246d2ef..fc8fb07777 100644 --- a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml @@ -204,6 +204,32 @@ backendTrafficPolicies: - value: 403 response: statusCode: 401 + - match: + responseHeaders: + - name: X-Custom-Header + value: + type: Exact + value: custom-value + response: + contentType: text/plain + body: + inline: "matched response header" + - match: + statusCodes: + - value: 503 + responseHeaders: + - name: X-Error-Type + value: + type: Prefix + value: "upstream-" + - name: X-Region + value: + type: RegularExpression + value: "^us-.*$" + response: + contentType: application/json + body: + inline: '{"error":"combined match"}' - apiVersion: gateway.envoyproxy.io/v1alpha1 kind: BackendTrafficPolicy metadata: diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml index 429804c25b..5d8bd18b22 100644 --- a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml @@ -68,6 +68,35 @@ backendTrafficPolicies: value: 403 response: statusCode: 401 + - match: + responseHeaders: + - name: X-Custom-Header + value: + type: Exact + value: custom-value + response: + body: + inline: matched response header + type: null + contentType: text/plain + - match: + responseHeaders: + - name: X-Error-Type + value: + type: Prefix + value: upstream- + - name: X-Region + value: + type: RegularExpression + value: ^us-.*$ + statusCodes: + - type: null + value: 503 + response: + body: + inline: '{"error":"combined match"}' + type: null + contentType: application/json targetRef: group: gateway.networking.k8s.io kind: HTTPRoute @@ -710,6 +739,29 @@ xdsIR: name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/0 response: statusCode: 401 + - match: + responseHeaders: + - distinct: false + exact: custom-value + name: X-Custom-Header + name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/1 + response: + body: bWF0Y2hlZCByZXNwb25zZSBoZWFkZXI= + contentType: text/plain + - match: + responseHeaders: + - distinct: false + name: X-Error-Type + prefix: upstream- + - distinct: false + name: X-Region + safeRegex: ^us-.*$ + statusCodes: + - value: 503 + name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/2 + response: + body: eyJlcnJvciI6ImNvbWJpbmVkIG1hdGNoIn0= + contentType: application/json - destination: metadata: kind: HTTPRoute diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 6d2fecc72d..50a8b087c1 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -827,10 +827,14 @@ type ResponseOverrideRule struct { } // CustomResponseMatch defines the configuration for matching a user response to return a custom one. +// When both statusCodes and responseHeaders are specified, both must match. // +k8s:deepcopy-gen=true type CustomResponseMatch struct { // Status code to match on. The match evaluates to true if any of the matches are successful. - StatusCodes []StatusCodeMatch `json:"statusCodes"` + StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` + + // Response headers to match on. The match evaluates to true if all matches are successful. + ResponseHeaders []StringMatch `json:"responseHeaders,omitempty"` } // StatusCodeMatch defines the configuration for matching a status code. diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index d6f990c42c..d8a9be6be9 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1285,6 +1285,13 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ResponseHeaders != nil { + in, out := &in.ResponseHeaders, &out.ResponseHeaders + *out = make([]StringMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponseMatch. diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go index 65977b6791..4373d712e7 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -77,7 +77,7 @@ func (c *customResponse) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener * return errs } -// buildHCMCustomResponseFilter returns an OAuth2 HTTP filter from the provided IR HTTPRoute. +// buildHCMCustomResponseFilter returns an Custom Response HTTP filter from the provided IR ResponseOverride. func (c *customResponse) buildHCMCustomResponseFilter(ro *ir.ResponseOverride) (*hcmv3.HttpFilter, error) { config, err := c.customResponseConfig(ro) if err != nil { @@ -106,62 +106,48 @@ func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3. for _, r := range ro.Rules { var ( - action *matcherv3.Matcher_OnMatch_Action - predicate *matcherv3.Matcher_MatcherList_Predicate - err error + action *matcherv3.Matcher_OnMatch_Action + predicates []*matcherv3.Matcher_MatcherList_Predicate + err error ) - if action, err = c.buildAction(r); err != nil { + if action, err = c.buildAction(&r); err != nil { return nil, err } - switch { - case len(r.Match.StatusCodes) == 0: - // This is just a sanity check, as the CRD validation should have caught this. - return nil, fmt.Errorf("missing status code in response override rule") - case len(r.Match.StatusCodes) == 1: - if predicate, err = c.buildSinglePredicate(r.Match.StatusCodes[0]); err != nil { + if len(r.Match.StatusCodes) > 0 { + statusCodePredicate, err := c.buildStatusCodePredicate(r.Match.StatusCodes) + if err != nil { return nil, err } - case len(r.Match.StatusCodes) > 1: - var predicates []*matcherv3.Matcher_MatcherList_Predicate - - for _, codeMatch := range r.Match.StatusCodes { - if predicate, err = c.buildSinglePredicate(codeMatch); err != nil { - return nil, err - } + predicates = append(predicates, statusCodePredicate) + } - predicates = append(predicates, predicate) + if len(r.Match.ResponseHeaders) > 0 { + headerPredicate, err := c.buildResponseHeaderPredicate(r.Match.ResponseHeaders) + if err != nil { + return nil, err } + predicates = append(predicates, headerPredicate) + } - // Create a single predicate that ORs all the status code predicates together. - predicate = &matcherv3.Matcher_MatcherList_Predicate{ - MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ - OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ - Predicate: predicates, - }, - }, - } + if len(predicates) == 0 { + // This is just a sanity check, as the CRD validation should have caught this. + return nil, fmt.Errorf("missing match criteria in response override rule") } - // For Local or Backend sources, AND the status code predicate with a - // local_reply predicate so the rule only fires for the correct response origin. + // For Local or Backend sources, AND a local_reply predicate so the rule + // only fires for the correct response origin. if r.Source == egv1a1.ResponseOverrideSourceLocal || r.Source == egv1a1.ResponseOverrideSourceBackend { localReplyPredicate, err := c.buildLocalReplyPredicate(r.Source == egv1a1.ResponseOverrideSourceLocal) if err != nil { return nil, err } - predicate = &matcherv3.Matcher_MatcherList_Predicate{ - MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ - AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ - Predicate: []*matcherv3.Matcher_MatcherList_Predicate{predicate, localReplyPredicate}, - }, - }, - } + predicates = append(predicates, localReplyPredicate) } matchers = append(matchers, &matcherv3.Matcher_MatcherList_FieldMatcher{ - Predicate: predicate, + Predicate: andPredicate(predicates), OnMatch: &matcherv3.Matcher_OnMatch{ OnMatch: action, }, @@ -183,7 +169,105 @@ func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3. return cr, nil } -func (c *customResponse) buildSinglePredicate(codeMatch ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { +func andPredicate(predicates []*matcherv3.Matcher_MatcherList_Predicate) *matcherv3.Matcher_MatcherList_Predicate { + if len(predicates) == 1 { + return predicates[0] + } + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ + AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + } +} + +func (c *customResponse) buildStatusCodePredicate(codes []ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + predicates := make([]*matcherv3.Matcher_MatcherList_Predicate, 0, len(codes)) + for _, codeMatch := range codes { + predicate, err := c.buildSingleStatusCodePredicate(codeMatch) + if err != nil { + return nil, err + } + predicates = append(predicates, predicate) + } + + if len(predicates) == 1 { + return predicates[0], nil + } + + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ + OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + }, nil +} + +func (c *customResponse) buildResponseHeaderPredicate(headers []ir.StringMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + predicates := make([]*matcherv3.Matcher_MatcherList_Predicate, 0, len(headers)) + for _, header := range headers { + input, err := c.buildResponseHeaderInput(header.Name) + if err != nil { + return nil, err + } + valueMatcher, err := buildStringMatcher(header) + if err != nil { + return nil, err + } + predicates = append(predicates, &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_{ + SinglePredicate: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate{ + Input: input, + Matcher: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_ValueMatch{ + ValueMatch: valueMatcher, + }, + }, + }, + }) + } + + if len(predicates) == 1 { + return predicates[0], nil + } + + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ + AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + }, nil +} + +func buildStringMatcher(irMatch ir.StringMatch) (*matcherv3.StringMatcher, error) { + switch { + case irMatch.Exact != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: *irMatch.Exact}, + }, nil + case irMatch.Prefix != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Prefix{Prefix: *irMatch.Prefix}, + }, nil + case irMatch.Suffix != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Suffix{Suffix: *irMatch.Suffix}, + }, nil + case irMatch.SafeRegex != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_SafeRegex{SafeRegex: &matcherv3.RegexMatcher{ + Regex: *irMatch.SafeRegex, + EngineType: &matcherv3.RegexMatcher_GoogleRe2{GoogleRe2: &matcherv3.RegexMatcher_GoogleRE2{}}, + }}, + }, nil + default: + return nil, fmt.Errorf("unsupported string matcher") + } +} + +func (c *customResponse) buildSingleStatusCodePredicate(codeMatch ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { var ( httpAttributeCELInput *cncfv3.TypedExtensionConfig statusCodeInput *cncfv3.TypedExtensionConfig @@ -266,6 +350,22 @@ func (c *customResponse) buildStatusCodeInput() (*cncfv3.TypedExtensionConfig, e }, nil } +func (c *customResponse) buildResponseHeaderInput(headerName string) (*cncfv3.TypedExtensionConfig, error) { + var ( + pb *anypb.Any + err error + ) + + if pb, err = proto.ToAnyWithValidation(&envoymatcherv3.HttpResponseHeaderMatchInput{HeaderName: headerName}); err != nil { + return nil, err + } + + return &cncfv3.TypedExtensionConfig{ + Name: "http-response-header-match-input", + TypedConfig: pb, + }, nil +} + func (c *customResponse) buildLocalReplyInput() (*cncfv3.TypedExtensionConfig, error) { pb, err := proto.ToAnyWithValidation(&envoymatcherv3.HttpResponseLocalReplyMatchInput{}) if err != nil { @@ -407,7 +507,7 @@ func (c *customResponse) buildStatusCodeCELMatcher(codeRange ir.StatusCodeRange) }, nil } -func (c *customResponse) buildAction(r ir.ResponseOverrideRule) (*matcherv3.Matcher_OnMatch_Action, error) { +func (c *customResponse) buildAction(r *ir.ResponseOverrideRule) (*matcherv3.Matcher_OnMatch_Action, error) { var ( pb *anypb.Any err error @@ -430,7 +530,7 @@ func (c *customResponse) buildAction(r ir.ResponseOverrideRule) (*matcherv3.Matc }, nil } -func (c *customResponse) buildRedirectAction(r ir.ResponseOverrideRule) (*anypb.Any, error) { +func (c *customResponse) buildRedirectAction(r *ir.ResponseOverrideRule) (*anypb.Any, error) { redirectAction := &routev3.RedirectAction{} if r.Redirect.Scheme != nil { redirectAction.SchemeRewriteSpecifier = &routev3.RedirectAction_SchemeRedirect{ @@ -458,7 +558,7 @@ func (c *customResponse) buildRedirectAction(r ir.ResponseOverrideRule) (*anypb. return proto.ToAnyWithValidation(redirect) } -func (c *customResponse) buildResponseAction(r ir.ResponseOverrideRule) (*anypb.Any, error) { +func (c *customResponse) buildResponseAction(r *ir.ResponseOverrideRule) (*anypb.Any, error) { response := &policyv3.LocalResponsePolicy{} if len(r.Response.Body) > 0 { diff --git a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml index 61e41caf85..a3487ffdf4 100644 --- a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml +++ b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml @@ -98,3 +98,23 @@ http: name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/0 response: statusCode: 404 + - match: + responseHeaders: + - name: X-Custom-Header + exact: custom-value + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/1 + response: + body: bWF0Y2hlZCByZXNwb25zZSBoZWFkZXI= + contentType: text/plain + - match: + statusCodes: + - value: 503 + responseHeaders: + - name: X-Error-Type + prefix: upstream- + - name: X-Region + safeRegex: "^us-.*$" + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/2 + response: + body: eyJlcnJvciI6ImNvbWJpbmVkIG1hdGNoIn0= + contentType: application/json diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml index 6d6c7d1e13..79262595d8 100644 --- a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml @@ -157,6 +157,69 @@ '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput valueMatch: exact: "403" + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/1 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + bodyFormat: + textFormat: matched response header + responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: text/plain + predicate: + singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Custom-Header + valueMatch: + exact: custom-value + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/2 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + bodyFormat: + textFormat: '{"error":"combined match"}' + responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: application/json + predicate: + andMatcher: + predicate: + - singlePredicate: + input: + name: http-response-status-code-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput + valueMatch: + exact: "503" + - andMatcher: + predicate: + - singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Error-Type + valueMatch: + prefix: upstream- + - singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Region + valueMatch: + safeRegex: + googleRe2: {} + regex: ^us-.*$ - name: envoy.filters.http.router typedConfig: '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/release-notes/current/new_features/9354-support-response-header-in-response-override-with-backendtrafficpolicy.md b/release-notes/current/new_features/9354-support-response-header-in-response-override-with-backendtrafficpolicy.md new file mode 100644 index 0000000000..16f6b8b302 --- /dev/null +++ b/release-notes/current/new_features/9354-support-response-header-in-response-override-with-backendtrafficpolicy.md @@ -0,0 +1 @@ +Added support for matching on response headers in `BackendTrafficPolicy` `responseOverride` via the `match.responseHeaders` field. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 20d3157df6..1c45c12c9b 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1384,13 +1384,15 @@ _Appears in:_ CustomResponseMatch defines the configuration for matching a user response to return a custom one. +When both statusCodes and responseHeaders are specified, both must match. _Appears in:_ - [ResponseOverride](#responseoverride) | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | -| `statusCodes` | _[StatusCodeMatch](#statuscodematch) array_ | true | | Status code to match on. The match evaluates to true if any of the matches are successful. | +| `statusCodes` | _[StatusCodeMatch](#statuscodematch) array_ | false | | Status code to match on. The match evaluates to true if any of the matches are successful. | +| `responseHeaders` | _[ResponseOverrideHeaderMatch](#responseoverrideheadermatch) array_ | false | | Response headers to match on. The match evaluates to true if all matches are successful. | #### CustomTag @@ -5782,6 +5784,21 @@ _Appears in:_ | `source` | _[ResponseOverrideSource](#responseoverridesource)_ | false | | Source specifies which responses this rule applies to.
Local overrides only Envoy-generated responses (e.g. auth failures).
Backend overrides only upstream responses.
All (default) overrides both. | +#### ResponseOverrideHeaderMatch + + + +ResponseOverrideHeaderMatch defines the configuration for matching a response header. + +_Appears in:_ +- [CustomResponseMatch](#customresponsematch) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | _[HTTPHeaderName](#httpheadername)_ | true | | Name of the HTTP header.
The header name is case-insensitive.
For example, "Foo" and "foo" are considered the same header. | +| `value` | _[StringMatch](#stringmatch)_ | true | | Value within the HTTP header to match against. | + + #### ResponseOverrideSource _Underlying type:_ _string_ @@ -6248,6 +6265,7 @@ _Appears in:_ - [OIDCDenyRedirectHeader](#oidcdenyredirectheader) - [OtherSANMatch](#othersanmatch) - [ProxyMetrics](#proxymetrics) +- [ResponseOverrideHeaderMatch](#responseoverrideheadermatch) - [SubjectAltNames](#subjectaltnames) | Field | Type | Required | Default | Description | diff --git a/test/cel-validation/backendtrafficpolicy_test.go b/test/cel-validation/backendtrafficpolicy_test.go index 422396efff..72db58e4f2 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -3021,6 +3021,114 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { "inline must be set for type Inline", }, }, + { + desc: "response header match in response override", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + ResponseHeaders: []egv1a1.ResponseOverrideHeaderMatch{ + { + Name: "X-Custom-Header", + Value: egv1a1.StringMatch{ + Type: new(egv1a1.StringMatchExact), + Value: "custom-value", + }, + }, + }, + }, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "status code and response header match in response override", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Value: new(503), + }, + }, + ResponseHeaders: []egv1a1.ResponseOverrideHeaderMatch{ + { + Name: "X-Error-Type", + Value: egv1a1.StringMatch{ + Type: new(egv1a1.StringMatchPrefix), + Value: "upstream-", + }, + }, + }, + }, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "neither status code nor response header in response override", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{}, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "at least one of statusCodes or responseHeaders must be specified", + }, + }, { desc: "both targetref and targetrefs specified", mutate: func(btp *egv1a1.BackendTrafficPolicy) { diff --git a/test/e2e/testdata/response-override.yaml b/test/e2e/testdata/response-override.yaml index 2bcebcf76a..6c8499ab3f 100644 --- a/test/e2e/testdata/response-override.yaml +++ b/test/e2e/testdata/response-override.yaml @@ -103,3 +103,18 @@ spec: path: type: ReplaceFullPath replaceFullPath: /status/301 + - match: + responseHeaders: + - name: X-Custom-Header + value: + type: Exact + value: custom-value + response: + contentType: text/plain + body: + type: Inline + inline: "matched on response header" + header: + set: + - name: X-Override-Matched + value: "true" diff --git a/test/e2e/tests/direct-response.go b/test/e2e/tests/direct-response.go index 30f10db58b..feb6c32d65 100644 --- a/test/e2e/tests/direct-response.go +++ b/test/e2e/tests/direct-response.go @@ -34,23 +34,39 @@ var DirectResponseTest = suite.ConformanceTest{ kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) // Test inline response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/inline", "text/plain", "GET Oops! Your request is not found.", 200, map[string]string{ - "X-Add-Header": "added-value", - "X-Set-Header": "set-value", - "X-Response-Type": "direct", - "Cache-Control": "no-cache", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/inline", + contentType: "text/plain", + body: "GET Oops! Your request is not found.", + statusCode: 200, + headers: map[string]string{ + "X-Add-Header": "added-value", + "X-Set-Header": "set-value", + "X-Response-Type": "direct", + "Cache-Control": "no-cache", + }, }) // Test value-ref response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/value-ref", "application/json", `{"error": "Internal Server Error"}`, 200, map[string]string{ - "X-Add-Header": "added-json", - "X-Set-Header": "set-json", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/value-ref", + contentType: "application/json", + body: `{"error": "Internal Server Error"}`, + statusCode: 200, + headers: map[string]string{ + "X-Add-Header": "added-json", + "X-Set-Header": "set-json", + }, }) // Test status-only response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/401", "", ``, 401, map[string]string{ - "X-Add-Header": "added-401", - "X-Set-Header": "set-401", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/401", + statusCode: 401, + headers: map[string]string{ + "X-Add-Header": "added-401", + "X-Set-Header": "set-401", + }, }) }) }, diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go index e6d8eb4048..57e2e6f488 100644 --- a/test/e2e/tests/response-override.go +++ b/test/e2e/tests/response-override.go @@ -50,32 +50,89 @@ var ResponseOverrideTest = suite.ConformanceTest{ BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "response-override", Namespace: ns}, suite.ControllerName, ancestorRef) // Test 404 response override with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/404", "text/plain", "404 Oops! Your request is not found.", 404, map[string]string{ - "X-Add-Header": "added-404", - "X-Set-Header": "set-404", - "X-Error-Type": "not-found", - "Cache-Control": "no-cache", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/status/404", + contentType: "text/plain", + body: "404 Oops! Your request is not found.", + statusCode: 404, + headers: map[string]string{ + "X-Add-Header": "added-404", + "X-Set-Header": "set-404", + "X-Error-Type": "not-found", + "Cache-Control": "no-cache", + }, }) // Test 500 response override with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/500", "application/json", `{"error": "Internal Server Error"}`, 500, map[string]string{ - "X-Add-Header": "added-500", - "X-Set-Header": "set-500", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/status/500", + contentType: "application/json", + body: `{"error": "Internal Server Error"}`, + statusCode: 500, + headers: map[string]string{ + "X-Add-Header": "added-500", + "X-Set-Header": "set-500", + }, }) // Test 403 response override with add and set headers (status override to 404) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/403", "", "", 404, map[string]string{ - "X-Add-Header": "added-403", - "X-Set-Header": "set-403", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/status/403", + statusCode: 404, + headers: map[string]string{ + "X-Add-Header": "added-403", + "X-Set-Header": "set-403", + }, + }) + + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/status/401", + statusCode: 301, + }) + + // Test header match response override and add X-Override-Matched header (body is also overridden) + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/response-override-header-match", + requestHeaders: map[string]string{"X-Echo-Set-Header": "X-Custom-Header: custom-value"}, + contentType: "text/plain", + body: "matched on response header", + statusCode: 200, + headers: map[string]string{"X-Override-Matched": "true"}, + }) + + // Test header match response override NOT doing anything because the header does not match + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/response-override-header-match", + requestHeaders: map[string]string{"X-Echo-Set-Header": "X-Custom-Header: other-value"}, + contentType: "application/json", + body: "matched on response header", + bodyNotEqual: true, + statusCode: 200, + }) + + // Test header match response override NOT doing anything because the header is never set + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ + path: "/response-override-header-match", + contentType: "application/json", + body: "matched on response header", + bodyNotEqual: true, + statusCode: 200, }) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/401", "", "", 301) }) }, } -func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr, - path, expectedContentType, expectedBody string, expectedStatusCode int, expectedHeaders ...map[string]string, -) { +type expectedResponse struct { + path string + requestHeaders map[string]string + contentType string + body string + bodyNotEqual bool + statusCode int + headers map[string]string +} + +func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr string, expected *expectedResponse) { if timeoutConfig == nil { t.Fatalf("timeoutConfig cannot be nil") } @@ -83,48 +140,59 @@ func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwA reqURL := url.URL{ Scheme: "http", Host: httputils.CalculateHost(t, gwAddr, "http"), - Path: path, + Path: expected.path, } httputils.AwaitConvergence(t, timeoutConfig.RequiredConsecutiveSuccesses, timeoutConfig.MaxTimeToConsistency, func(_ time.Duration) bool { - rsp, err := http.Get(reqURL.String()) + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) if err != nil { - tlog.Logf(t, "failed to get response: %v", err) + tlog.Logf(t, "failed to create request: %v", err) return false } + for k, v := range expected.requestHeaders { + req.Header.Set(k, v) + } - // Verify that the response body is overridden - defer rsp.Body.Close() - body, err := io.ReadAll(rsp.Body) + rsp, err := http.DefaultClient.Do(req) if err != nil { - tlog.Logf(t, "failed to read response body: %v", err) + tlog.Logf(t, "failed to get response: %v", err) return false } - if string(body) != expectedBody { - tlog.Logf(t, "expected response body to be %s but got %s", expectedBody, string(body)) + + if expected.statusCode != rsp.StatusCode { + tlog.Logf(t, "expected status code to be %d but got %d", expected.statusCode, rsp.StatusCode) return false } // Verify that the content type is overridden - contentType := rsp.Header.Get("Content-Type") - if contentType != expectedContentType { - tlog.Logf(t, "expected content type to be %s but got %s", expectedContentType, contentType) + if contentType := rsp.Header.Get("Content-Type"); contentType != expected.contentType { + tlog.Logf(t, "expected content type to be %s but got %s", expected.contentType, contentType) + return false + } + + // Verify that the response body is (or, when bodyNotEqual is set, is not) overridden + defer rsp.Body.Close() + body, err := io.ReadAll(rsp.Body) + if err != nil { + tlog.Logf(t, "failed to read response body: %v", err) return false } - if expectedStatusCode != rsp.StatusCode { - tlog.Logf(t, "expected status code to be %d but got %d", expectedStatusCode, rsp.StatusCode) + if expected.bodyNotEqual { + if string(body) == expected.body { + tlog.Logf(t, "expected response body NOT to be %s", expected.body) + return false + } + } else if string(body) != expected.body { + tlog.Logf(t, "expected response body to be %s but got %s", expected.body, string(body)) return false } // Verify expected headers if provided - if len(expectedHeaders) > 0 { - for headerName, expectedValue := range expectedHeaders[0] { - actualValue := rsp.Header.Get(headerName) - if actualValue != expectedValue { - tlog.Logf(t, "expected header %s to be %s but got %s", headerName, expectedValue, actualValue) - return false - } + for headerName, expectedValue := range expected.headers { + if actualValue := rsp.Header.Get(headerName); actualValue != expectedValue { + tlog.Logf(t, "expected header %s to be %s but got %s", headerName, expectedValue, actualValue) + return false } } diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index e8ae71f7e4..4e5ee99f20 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -26999,6 +26999,52 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if all matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -27054,9 +27100,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index 8d51ce19a8..e91a01cb22 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -2937,6 +2937,52 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if all matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2992,9 +3038,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index a8dccebf69..7341d49608 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -2937,6 +2937,52 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if all matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2992,9 +3038,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: