Repository: envoyproxy/gateway

Issue:
Global response filter for all HTTPRoutes
*Description*:

It would be great if you could globally add a response filter to all HTTP routes, so that you can manipulate response headers for all routes. For example, your backends may return a header that you don't want to return to clients. Or, you may want to add a security header such as `Strict-Transport-Security` header for all routes. Currently to do this you need to add the response filter individually to each `HTTPRoute`.

It may also be nice to have the ability to override or merge the filters at the HTTP route level.

This could maybe be achieved using Policy Attachment, as suggested in this upstream discussion https://github.com/kubernetes-sigs/gateway-api/issues/1973

Or it may be better to implement it in a similar fashion to [early request modification](https://gateway.envoyproxy.io/docs/tasks/traffic/http-request-headers/#early-header-modification), perhaps with a `lateResponseHeaders` config?

[optional *Relevant Links*:]

This would be equivalent to nginx's `proxy_hide_header` [directive](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_hide_header) which can be applied globally by adding it to the `http` block. 

Hints from issue comments:
@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@jgoodall: One use we would have for this is setting `Strict-Transport-Security` on all routes in one place.@arkodg: +1 to a   `responseHeaders`/`lateresponseHeaders` field in CTP https://gateway.envoyproxy.io/latest/api/extension_types/#headersettings

this is supported in the header mutation filter under `response_mutations` https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/header_mutation/v3/header_mutation.proto#extensions-filters-http-header-mutation-v3-mutations@twelvelabs: @arkodg @lboynton Is someone actively working on this? If not, I'd love to give it a go 😃

My plan was to:

1. Add `.spec.headers.lateResponseHeaders` to CTP.
2. Add `LateAddResponseHeaders` and `LateRemoveResponseHeaders` to `HeaderSettings` in the `ir` package (along with the translator boilerplate to populate them).
3. Register a header mutation filter over in the XDS translator if either of those two fields are set.

I wanted to get familiar w/ the code base before committing to anything, so I tried implementing the first two here: https://github.com/envoyproxy/gateway/compare/main...twelvelabs:late-response-headers

Y'all mind if I finish that out and submit a PR?@arkodg: Plan looks solid, go for it @twelvelabs @twelvelabs: Fixed in https://github.com/envoyproxy/gateway/pull/6851

Solution patch:
diff --git a/.github/workflows/build_and_test.yaml b/.github/workflows/build_and_test.yaml
index 91ee1937a..4728272ba 100644
--- a/.github/workflows/build_and_test.yaml
+++ b/.github/workflows/build_and_test.yaml
@@ -78,7 +78,7 @@ jobs:
     - name: Run Coverage Tests
       run: make go.test.coverage
     - name: Upload coverage to Codecov
-      uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00  # v5.5.0
+      uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7  # v5.5.1
       with:
         fail_ci_if_error: true
         files: ./coverage.xml
diff --git a/api/v1alpha1/clienttrafficpolicy_types.go b/api/v1alpha1/clienttrafficpolicy_types.go
index abc7d59df..c21323bb8 100644
--- a/api/v1alpha1/clienttrafficpolicy_types.go
+++ b/api/v1alpha1/clienttrafficpolicy_types.go
@@ -159,6 +159,11 @@ type HeaderSettings struct {
 	//
 	// +optional
 	EarlyRequestHeaders *HTTPHeaderFilter `json:"earlyRequestHeaders,omitempty"`
+
+	// LateResponseHeaders defines settings for global response header modification.
+	//
+	// +optional
+	LateResponseHeaders *HTTPHeaderFilter `json:"lateResponseHeaders,omitempty"`
 }
 
 // WithUnderscoresAction configures the action to take when an HTTP header with underscores
diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go
index 8eeabc642..f363f2f83 100644
--- a/api/v1alpha1/envoyproxy_types.go
+++ b/api/v1alpha1/envoyproxy_types.go
@@ -293,6 +293,9 @@ const (
 	// EnvoyFilterBuffer defines the Envoy HTTP buffer filter
 	EnvoyFilterBuffer EnvoyFilter = "envoy.filters.http.buffer"
 
+	// EnvoyFilterHeaderMutation defines the Envoy HTTP header mutation filter
+	EnvoyFilterHeaderMutation EnvoyFilter = "envoy.filters.http.header_mutation"
+
 	// StatFormatterRouteName defines the Route Name formatter for stats
 	StatFormatterRouteName string = "%ROUTE_NAME%"
 
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 4247358f5..2800d537d 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -3695,6 +3695,11 @@ func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) {
 		*out = new(HTTPHeaderFilter)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.LateResponseHeaders != nil {
+		in, out := &in.LateResponseHeaders, &out.LateResponseHeaders
+		*out = new(HTTPHeaderFilter)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderSettings.
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 6183885f6..be18396de 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
@@ -329,6 +329,136 @@ spec:
                       EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
                       and responses.
                     type: boolean
+                  lateResponseHeaders:
+                    description: LateResponseHeaders defines settings for global response
+                      header modification.
+                    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: 64
+                        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: 64
+                        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: 64
+                        type: array
+                        x-kubernetes-list-map-keys:
+                        - name
+                        x-kubernetes-list-type: map
+                    type: object
                   preserveXRequestID:
                     description: |-
                       PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge
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 11673671f..f9f41dfac 100644
--- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
+++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml
@@ -328,6 +328,136 @@ spec:
                       EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
                       and responses.
                     type: boolean
+                  lateResponseHeaders:
+                    description: LateResponseHeaders defines settings for global response
+                      header modification.
+                    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: 64
+                        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: 64
+                        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: 64
+                        type: array
+                        x-kubernetes-list-map-keys:
+                        - name
+                        x-kubernetes-list-type: map
+                    type: object
                   preserveXRequestID:
                     description: |-
                       PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge
diff --git a/internal/gatewayapi/clienttrafficpolicy.go b/internal/gatewayapi/clienttrafficpolicy.go
index 53cb8538b..32da7adad 100644
--- a/internal/gatewayapi/clienttrafficpolicy.go
+++ b/internal/gatewayapi/clienttrafficpolicy.go
@@ -657,15 +657,27 @@ func translateListenerHeaderSettings(headerSettings *egv1a1.HeaderSettings, http
 		}
 	}
 
+	var errs error
+
 	if headerSettings.EarlyRequestHeaders != nil {
-		headersToAdd, headersToRemove, err := translateEarlyRequestHeaders(headerSettings.EarlyRequestHeaders)
+		headersToAdd, headersToRemove, err := translateHeaderModifier(headerSettings.EarlyRequestHeaders, "EarlyRequestHeaders")
 		if err != nil {
-			return err
+			errs = errors.Join(errs, err)
 		}
 		httpIR.Headers.EarlyAddRequestHeaders = headersToAdd
 		httpIR.Headers.EarlyRemoveRequestHeaders = headersToRemove
 	}
-	return nil
+
+	if headerSettings.LateResponseHeaders != nil {
+		headersToAdd, headersToRemove, err := translateHeaderModifier(headerSettings.LateResponseHeaders, "LateResponseHeaders")
+		if err != nil {
+			errs = errors.Join(errs, err)
+		}
+		httpIR.Headers.LateAddResponseHeaders = headersToAdd
+		httpIR.Headers.LateRemoveResponseHeaders = headersToRemove
+	}
+
+	return errs
 }
 
 func translateHTTP1Settings(http1Settings *egv1a1.HTTP1Settings, httpIR *ir.HTTPListener) error {
@@ -964,7 +976,7 @@ func buildConnection(connection *egv1a1.ClientConnection) (*ir.ClientConnection,
 	return irConnection, nil
 }
 
-func translateEarlyRequestHeaders(headerModifier *egv1a1.HTTPHeaderFilter) ([]ir.AddHeader, []string, error) {
+func translateHeaderModifier(headerModifier *egv1a1.HTTPHeaderFilter, modType string) ([]ir.AddHeader, []string, error) {
 	// Make sure the header modifier config actually exists
 	if headerModifier == nil {
 		return nil, nil, nil
@@ -983,18 +995,18 @@ func translateEarlyRequestHeaders(headerModifier *egv1a1.HTTPHeaderFilter) ([]ir
 		for _, addHeader := range headersToAdd {
 			emptyFilterConfig = false
 			if addHeader.Name == "" {
-				errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot add a header with an empty name"))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot add a header with an empty name", modType))
 				// 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 cannot add a header with a '/' or ':' character in them. Header: '%q'", string(addHeader.Name)))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot add a header with a '/' or ':' character in them. Header: '%q'", modType, string(addHeader.Name)))
 				continue
 			}
 			// Gateway API specification allows only valid value as defined by RFC 7230
 			if !HeaderValueRegexp.MatchString(addHeader.Value) {
-				errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot add a header with an invalid value. Header: '%q'", string(addHeader.Name)))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot add a header with an invalid value. Header: '%q'", modType, string(addHeader.Name)))
 				continue
 			}
 			// Check if the header is a duplicate
@@ -1029,17 +1041,17 @@ func translateEarlyRequestHeaders(headerModifier *egv1a1.HTTPHeaderFilter) ([]ir
 		for _, setHeader := range headersToSet {
 
 			if setHeader.Name == "" {
-				errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot set a header with an empty name"))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot set a header with an empty name", modType))
 				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 a header with a '/' or ':' character in them. Header: '%q'", string(setHeader.Name)))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot set a header with a '/' or ':' character in them. Header: '%q'", modType, string(setHeader.Name)))
 				continue
 			}
 			// Gateway API specification allows only valid value as defined by RFC 7230
 			if !HeaderValueRegexp.MatchString(setHeader.Value) {
-				errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot set a header with an invalid value. Header: '%q'", string(setHeader.Name)))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot set a header with an invalid value. Header: '%q'", modType, string(setHeader.Name)))
 				continue
 			}
 
@@ -1074,7 +1086,7 @@ func translateEarlyRequestHeaders(headerModifier *egv1a1.HTTPHeaderFilter) ([]ir
 		}
 		for _, removedHeader := range headersToRemove {
 			if removedHeader == "" {
-				errs = errors.Join(errs, fmt.Errorf("EarlyRequestHeaders cannot remove a header with an empty name"))
+				errs = errors.Join(errs, fmt.Errorf("%s cannot remove a header with an empty name", modType))
 				continue
 			}
 
@@ -1095,7 +1107,7 @@ func translateEarlyRequestHeaders(headerModifier *egv1a1.HTTPHeaderFilter) ([]ir
 
 	// 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"))
+		errs = errors.Join(errs, fmt.Errorf("%s did not provide valid configuration to add/set/remove any headers", modType))
 	}
 
 	return AddRequestHeaders, RemoveRequestHeaders, errs
diff --git a/internal/gatewayapi/envoyextensionpolicy.go b/internal/gatewayapi/envoyextensionpolicy.go
index a2515fe97..49ae4b8c0 100644
--- a/internal/gatewayapi/envoyextensionpolicy.go
+++ b/internal/gatewayapi/envoyextensionpolicy.go
@@ -635,7 +635,6 @@ func (t *Translator) buildLua(
 	envoyProxy *egv1a1.EnvoyProxy,
 ) (*ir.Lua, error) {
 	var luaCode *string
-	var luaValidation egv1a1.LuaValidation
 	var err error
 	if lua.Type == egv1a1.LuaValueTypeValueRef {
 		luaCode, err = getLuaBodyFromLocalObjectReference(lua.ValueRef, resources, policy.Namespace)
@@ -645,12 +644,8 @@ func (t *Translator) buildLua(
 	if err != nil {
 		return nil, err
 	}
-	if envoyProxy != nil && envoyProxy.Spec.LuaValidation != nil {
-		luaValidation = *envoyProxy.Spec.LuaValidation
-	} else {
-		luaValidation = egv1a1.LuaValidationStrict
-	}
-	if err = luavalidator.NewLuaValidator(*luaCode, luaValidation).Validate(); err != nil {
+
+	if err = luavalidator.NewLuaValidator(*luaCode, envoyProxy).Validate(); err != nil {
 		return nil, fmt.Errorf("validation failed for lua body in policy with name %v: %w", name, err)
 	}
 	return &ir.Lua{
diff --git a/internal/gatewayapi/luavalidator/lua_validator.go b/internal/gatewayapi/luavalidator/lua_validator.go
index d3628f51b..1c103ee17 100644
--- a/internal/gatewayapi/luavalidator/lua_validator.go
+++ b/internal/gatewayapi/luavalidator/lua_validator.go
@@ -30,14 +30,14 @@ var mockData []byte
 // Validation strictness is controlled by the validation field
 type LuaValidator struct {
 	code       string
-	validation egv1a1.LuaValidation
+	envoyProxy *egv1a1.EnvoyProxy
 }
 
 // NewLuaValidator returns a LuaValidator for user provided Lua code
-func NewLuaValidator(code string, validation egv1a1.LuaValidation) *LuaValidator {
+func NewLuaValidator(code string, envoyProxy *egv1a1.EnvoyProxy) *LuaValidator {
 	return &LuaValidator{
 		code:       code,
-		validation: validation,
+		envoyProxy: envoyProxy,
 	}
 }
 
@@ -61,7 +61,7 @@ func (l *LuaValidator) Validate() error {
 
 // validate runs the validation on given code
 func (l *LuaValidator) validate(code string) error {
-	switch l.validation {
+	switch l.getLuaValidation() {
 	case egv1a1.LuaValidationSyntax:
 		return l.loadLua(code)
 	case egv1a1.LuaValidationDisabled:
@@ -71,10 +71,28 @@ func (l *LuaValidator) validate(code string) error {
 	}
 }
 
+// getLuaValidation returns the Lua validation level, defaulting to strict if not configured
+func (l *LuaValidator) getLuaValidation() egv1a1.LuaValidation {
+	if l.envoyProxy != nil && l.envoyProxy.Spec.LuaValidation != nil {
+		return *l.envoyProxy.Spec.LuaValidation
+	}
+	return egv1a1.LuaValidationStrict
+}
+
+// newLuaState creates a new Lua state with global settings applied
+func (l *LuaValidator) newLuaState() *lua.LState {
+	L := lua.NewState()
+	// Suppress all print statements
+	L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int {
+		return 0
+	}))
+	return L
+}
+
 // runLua interprets and runs the provided Lua code in runtime using gopher-lua
 // Refer: https://github.com/yuin/gopher-lua?tab=readme-ov-file#differences-between-lua-and-gopherlua
 func (l *LuaValidator) runLua(code string) error {
-	L := lua.NewState()
+	L := l.newLuaState()
 	defer L.Close()
 	if err := L.DoString(code); err != nil {
 		return err
@@ -85,7 +103,7 @@ func (l *LuaValidator) runLua(code string) error {
 // loadLua loads the Lua code into the Lua state, does not run it
 // This is used to check for syntax errors in the Lua code
 func (l *LuaValidator) loadLua(code string) error {
-	L := lua.NewState()
+	L := l.newLuaState()
 	defer L.Close()
 	if _, err := L.LoadString(code); err != nil {
 		return err
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index 771f5e6e1..063b42946 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -701,6 +701,12 @@ type HeaderSettings struct {
 
 	// EarlyRemoveRequestHeaders defines headers that would be removed before envoy request processing.
 	EarlyRemoveRequestHeaders []string `json:"earlyRemoveRequestHeaders,omitempty" yaml:"earlyRemoveRequestHeaders,omitempty"`
+
+	// LateAddResponseHeaders defines headers that would be added after envoy response processing.
+	LateAddResponseHeaders []AddHeader `json:"lateAddResponseHeaders,omitempty" yaml:"earlyAddRequestHeaders,omitempty"`
+
+	// LateRemoveResponseHeaders defines headers that would be removed after envoy response processing.
+	LateRemoveResponseHeaders []string `json:"lateRemoveResponseHeaders,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 da91baaf6..f6c634657 100644
--- a/internal/ir/zz_generated.deepcopy.go
+++ b/internal/ir/zz_generated.deepcopy.go
@@ -2023,6 +2023,18 @@ func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) {
 		*out = make([]string, len(*in))
 		copy(*out, *in)
 	}
+	if in.LateAddResponseHeaders != nil {
+		in, out := &in.LateAddResponseHeaders, &out.LateAddResponseHeaders
+		*out = make([]AddHeader, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.LateRemoveResponseHeaders != nil {
+		in, out := &in.LateRemoveResponseHeaders, &out.LateRemoveResponseHeaders
+		*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/header_mutation.go b/internal/xds/translator/header_mutation.go
new file mode 100644
index 000000000..286014339
--- /dev/null
+++ b/internal/xds/translator/header_mutation.go
@@ -0,0 +1,145 @@
+// 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 translator
+
+import (
+	"errors"
+
+	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"
+	routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
+	mutationv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/header_mutation/v3"
+	hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
+
+	egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
+	"github.com/envoyproxy/gateway/internal/ir"
+	"github.com/envoyproxy/gateway/internal/utils/proto"
+	"github.com/envoyproxy/gateway/internal/xds/types"
+)
+
+func init() {
+	registerHTTPFilter(&headerMutation{})
+}
+
+type headerMutation struct{}
+
+var _ httpFilter = &headerMutation{}
+
+// patchHCM builds and appends the header mutation filter to the HTTP Connection Manager
+// if applicable, and it does not already exist.
+func (*headerMutation) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error {
+	if mgr == nil {
+		return errors.New("hcm is nil")
+	}
+	if irListener == nil {
+		return errors.New("ir listener is nil")
+	}
+
+	if hcmContainsFilter(mgr, egv1a1.EnvoyFilterHeaderMutation.String()) {
+		return nil
+	}
+
+	filter, err := buildHeaderMutationFilter(irListener.Headers)
+	if err != nil {
+		return err
+	}
+	if filter != nil {
+		mgr.HttpFilters = append(mgr.HttpFilters, filter)
+	}
+
+	return nil
+}
+
+func (*headerMutation) patchResources(*types.ResourceVersionTable, []*ir.HTTPRoute) error {
+	return nil
+}
+
+func (*headerMutation) patchRoute(*routev3.Route, *ir.HTTPRoute, *ir.HTTPListener) error {
+	return nil
+}
+
+func buildHeaderMutationFilter(headers *ir.HeaderSettings) (*hcmv3.HttpFilter, error) {
+	if headers == nil {
+		return nil, nil
+	}
+
+	addHeaders := headers.LateAddResponseHeaders
+	removeHeaders := headers.LateRemoveResponseHeaders
+	if len(addHeaders) == 0 && len(removeHeaders) == 0 {
+		return nil, nil
+	}
+
+	mutationProto := &mutationv3.HeaderMutation{
+		Mutations: &mutationv3.Mutations{
+			ResponseMutations: buildHeaderMutationRules(addHeaders, removeHeaders),
+		},
+	}
+
+	mutationAny, err := proto.ToAnyWithValidation(mutationProto)
+	if err != nil {
+		return nil, err
+	}
+
+	return &hcmv3.HttpFilter{
+		Name: egv1a1.EnvoyFilterHeaderMutation.String(),
+		ConfigType: &hcmv3.HttpFilter_TypedConfig{
+			TypedConfig: mutationAny,
+		},
+	}, nil
+}
+
+func buildHeaderMutationRules(addHeaders []ir.AddHeader, removeHeaders []string) []*mutation_rulesv3.HeaderMutation {
+	var mutationRules []*mutation_rulesv3.HeaderMutation
+
+	for _, header := range addHeaders {
+		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 removeHeaders {
+		mr := &mutation_rulesv3.HeaderMutation{
+			Action: &mutation_rulesv3.HeaderMutation_Remove{
+				Remove: header,
+			},
+		}
+		mutationRules = append(mutationRules, mr)
+	}
+
+	return mutationRules
+}
diff --git a/internal/xds/translator/httpfilters.go b/internal/xds/translator/httpfilters.go
index ff68e89d2..7efb42f2f 100644
--- a/internal/xds/translator/httpfilters.go
+++ b/internal/xds/translator/httpfilters.go
@@ -140,8 +140,10 @@ func newOrderedHTTPFilter(filter *hcmv3.HttpFilter) *OrderedHTTPFilter {
 		order = 307
 	case isFilterType(filter, egv1a1.EnvoyFilterCompressor):
 		order = 308
-	case isFilterType(filter, egv1a1.EnvoyFilterRouter):
+	case isFilterType(filter, egv1a1.EnvoyFilterHeaderMutation):
 		order = 309
+	case isFilterType(filter, egv1a1.EnvoyFilterRouter):
+		order = 310
 	}
 
 	return &OrderedHTTPFilter{
diff --git a/internal/xds/translator/listener.go b/internal/xds/translator/listener.go
index 943e46cc5..723411f0d 100644
--- a/internal/xds/translator/listener.go
+++ b/internal/xds/translator/listener.go
@@ -14,7 +14,6 @@ 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"
@@ -536,58 +535,8 @@ func buildEarlyHeaderMutation(headers *ir.HeaderSettings) []*corev3.TypedExtensi
 		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, _ := proto.ToAnyWithValidation(&early_header_mutationv3.HeaderMutation{
-		Mutations: mutationRules,
+		Mutations: buildHeaderMutationRules(headers.EarlyAddRequestHeaders, headers.EarlyRemoveRequestHeaders),
 	})
 
 	return []*corev3.TypedExtensionConfig{
diff --git a/release-notes/current.yaml b/release-notes/current.yaml
index 35717469e..e8207d54f 100644
--- a/release-notes/current.yaml
+++ b/release-notes/current.yaml
@@ -8,7 +8,8 @@ security updates: |
 
 # New features or capabilities added in this release.
 new features: |
-  Added support for mTLS configuration for ExtensionServer
+  Added support for mTLS configuration for ExtensionServer.
+  Added support for late response headers in ClientTrafficPolicy.
 
 bug fixes: |
   Fixed %ROUTE_KIND% operator to be lower-cased when used by clusterStatName in EnvoyProxy API.
@@ -19,6 +20,7 @@ bug fixes: |
   Fixed the controller cannot read the EnvoyProxy attached gatewayclass only.
   Fixed handling of millisecond-level retry durations and token TTLs in OIDC authn.
   Fixed indexer and controller crashing when BackendTrafficPolicy has a redirect response override.
+  Fixed Lua validator log level to be suppressed by default.
 
 # Enhancements that improve performance.
 performance improvements: |
diff --git a/site/content/en/v1.5/tasks/observability/gateway-api-metadata.md b/site/content/en/v1.5/tasks/observability/gateway-api-metadata.md
new file mode 100644
index 000000000..999246a07
--- /dev/null
+++ b/site/content/en/v1.5/tasks/observability/gateway-api-metadata.md
@@ -0,0 +1,89 @@
+---
+title: "Gateway API Metadata"
+---
+
+## Background 
+Envoy Gateway translates Gateway API resources to Envoy XDS resources. In this translation process, Envoy Gateway annotates XDS resources with additional metadata from their origin Gateway API resources. 
+
+Gateway API Metadata includes:
+- K8s Resource Kinds, Names and Namespaces.
+- K8s Resource Annotations with the `gateway.envoyproxy.io/` prefix.
+- K8s Resource SectionNames (when applicable, e.g. for Route rules and Listeners).
+
+Gateway API Metadata is added to XDS resources using envoy's [Static Metadata][] under `metadata.filter_metadata.envoy-gateway.resources`. Currently, `resources` only contains the primary origin resource. 
+However, in the future, additional relevant resources (e.g. policies, filters attached to the primary origin resources) may be added. 
+
+## Supported Resources 
+Currently, the following mapping of Gateway API metadata to XDS metadata are supported:
+
+| XDS Resource     | Primary Gateway API Resource                           | XDS Metadata                                                                        | Comments
+|------------------|--------------------------------------------------------|-------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
+| [Virtual Host][] | `Gateway`                                              | Kind, Namespace, Name, Annotations, SectionName (`spec.listeners.<listener>.name`)  |                                                                                   |
+| [Route][]        | `HTTPRoute`, `GRPCRoute`                               | Kind, Namespace, Name, Annotations, SectionName (`spec.listener.rules.<rule>.name`) |                                                                                   | 
+| [Cluster][]      | `xRoute`                                               | Kind, Namespace, Name, Annotations, SectionName (`spec.listener.rules.<rule>.name`) |                                                                                   |
+| [Cluster][]      | `EnvoyProxy`, `EnvoyExtensionPolicy`, `SecurityPolicy` | Kind, Namespace, Name, Annotations, SectionName (`spec.listener.rules.<rule>.name`) |  When a non-xRoute BackendRef is used (e.g. ext_auth, observabiltiy sink, ... )   |
+| [LBEndpoints][]  | `Service`, `ServiceImport`, `Backend`                  | Kind, Namespace, Name, Annotations, SectionName (`backendRef.port`)                 |                                                                                   |
+
+For example, consider the following Gateway API HTTPRoute:
+
+```yaml
+kind: HTTPRoute
+apiVersion: gateway.networking.k8s.io/v1
+metadata:
+  annotations:
+    gateway.envoyproxy.io/foo: bar
+  name: myroute
+  namespace: myns
+spec:
+  rules:
+  - name: myrule
+    matches:
+    - path:
+        type: PathPrefix
+        value: /mypath
+```
+
+The translated XDS [Route][] contains Gateway API metadata under :
+
+```yaml
+name: httproute/myns/myroute/rule/0/match/0/*
+match:
+  path_separated_prefix: "/mypath"
+route:
+  cluster: httproute/myns/myroute/rule/0
+metadata:
+  filter_metadata:
+    envoy-gateway:
+      resources:
+      - namespace: myns
+        kind: HTTPRoute
+        annotations:
+          foo: bar
+        name: myroute
+        sectionName: myrule
+```
+
+## Use Cases
+XDS Metadata serves multiple purposes:
+- Observability: Envoy proxy access logs can be [enriched with Gateway-API resource](./proxy-accesslog.md) context and custom annotations, creating an association with relevant Application Developers personas.
+- Troubleshooting: users and tools that analyze the envoy proxy XDS config can identify the Gateway API resources that lead to the XDS configuration's creation.
+- Extensibility: 
+  - Envoy Gateway [Extension Servers][] can leverage Gateway API metadata as additional context annotating XDS resources sent for mutation.  
+  - Envoy Proxy extensions can leverage XDS metadata as additional context when processing traffic:
+    - [lua][] extensions can access metadata using the [Metadata Stream handle API][] .
+    - [ext_proc][] extensions can access metadata using the `xds.*_metadata` [ext_proc attribute][]. 
+  
+
+[Static Metadata]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/core/v3/base.proto#envoy-v3-api-msg-config-core-v3-metadata
+[Virtual Host]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-virtualhost
+[Route]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-msg-config-route-v3-route
+[Cluster]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto.html
+[LBEndpoints]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/endpoint/v3/endpoint_components.proto#envoy-v3-api-msg-config-endpoint-v3-lbendpoint
+[Metadata Stream handle API]: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter#metadata
+[lua]: ../../api/extension_types#lua
+[ext_proc]: ../../api/extension_types#extproc
+[ext_proc attribute]: ../../api/extension_types#processingmodeoptions
+[Extension Servers]: ../extensibility/extension-server.md
+
+
+
diff --git a/site/content/en/v1.5/tasks/observability/proxy-accesslog.md b/site/content/en/v1.5/tasks/observability/proxy-accesslog.md
index cad48129e..a7ab6a65e 100644
--- a/site/content/en/v1.5/tasks/observability/proxy-accesslog.md
+++ b/site/content/en/v1.5/tasks/observability/proxy-accesslog.md
@@ -239,12 +239,15 @@ curl -s "http://$LOKI_IP:3100/loki/api/v1/query_range" --data-urlencode "query={
 ```
 
 
-## Additional Metadata
+## Gateway API Metadata
 
 Envoy Gateway provides additional metadata about the K8s resources that were translated to  certain envoy resources.
 For example, details about the `HTTPRoute` and `GRPCRoute` (kind, group, name, namespace and annotations) are available
-for access log formatter using the `METADATA` operator. To enrich logs, users can add log operator such as:
-`%METADATA(ROUTE:envoy-gateway:resources)%` to their access log format. 
+for access log formatter using the `METADATA` operator. 
+
+To enrich logs, users can add log operator that refer to XDS metadata, such as:
+- `%METADATA(ROUTE:envoy-gateway:resources)%`
+- `%CEL(xds.route_metadata.filter_metadata['envoy-gateway']['resources'][0]['name'])%`
 
 ## Access Log Types
 



Test patch:
diff --git a/internal/gatewayapi/luavalidator/lua_validator_test.go b/internal/gatewayapi/luavalidator/lua_validator_test.go
index fb5ca5604..0b57c2742 100644
--- a/internal/gatewayapi/luavalidator/lua_validator_test.go
+++ b/internal/gatewayapi/luavalidator/lua_validator_test.go
@@ -9,6 +9,8 @@ import (
 	"strings"
 	"testing"
 
+	"k8s.io/utils/ptr"
+
 	egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
 )
 
@@ -16,7 +18,7 @@ func Test_Validate(t *testing.T) {
 	type args struct {
 		name                 string
 		code                 string
-		validation           egv1a1.LuaValidation
+		proxy                *egv1a1.EnvoyProxy
 		expectedErrSubstring string
 	}
 	tests := []args{
@@ -28,7 +30,7 @@ func Test_Validate(t *testing.T) {
 		{
 			name: "logInfo: envoy_on_response",
 			code: `function envoy_on_response(response_handle)
-                     response_handle:logInfo("Goodbye.")
+                     response_handle:logInfo("This log should not be printed.")
                    end`,
 			expectedErrSubstring: "",
 		},
@@ -156,7 +158,11 @@ func Test_Validate(t *testing.T) {
 			code: `function envoy_on_request(request_handle)
                      request_handle:unknownApi()
                    end`,
-			validation:           egv1a1.LuaValidationSyntax,
+			proxy: &egv1a1.EnvoyProxy{
+				Spec: egv1a1.EnvoyProxySpec{
+					LuaValidation: ptr.To(egv1a1.LuaValidationSyntax),
+				},
+			},
 			expectedErrSubstring: "",
 		},
 		{
@@ -173,7 +179,11 @@ func Test_Validate(t *testing.T) {
 
                      last:setBytes("<html><b>Not Found<b></html>")
                    end`,
-			validation:           egv1a1.LuaValidationSyntax,
+			proxy: &egv1a1.EnvoyProxy{
+				Spec: egv1a1.EnvoyProxySpec{
+					LuaValidation: ptr.To(egv1a1.LuaValidationSyntax),
+				},
+			},
 			expectedErrSubstring: "<string> at EOF:   syntax error",
 		},
 		{
@@ -190,13 +200,17 @@ func Test_Validate(t *testing.T) {
 
                      last:setBytes("<html><b>Not Found<b></html>")
                    end`,
-			validation:           egv1a1.LuaValidationDisabled,
+			proxy: &egv1a1.EnvoyProxy{
+				Spec: egv1a1.EnvoyProxySpec{
+					LuaValidation: ptr.To(egv1a1.LuaValidationDisabled),
+				},
+			},
 			expectedErrSubstring: "",
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			l := NewLuaValidator(tt.code, tt.validation)
+			l := NewLuaValidator(tt.code, tt.proxy)
 			if err := l.Validate(); err != nil && tt.expectedErrSubstring == "" {
 				t.Errorf("Unexpected error: %v", err)
 			} else if err != nil && !strings.Contains(err.Error(), tt.expectedErrSubstring) {
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml
index 5fb1efb47..bca9ec93f 100644
--- a/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.in.yaml
@@ -27,6 +27,24 @@ clientTrafficPolicies:
           value: "  invalid"
         remove:
         - ""
+      lateResponseHeaders:
+        add:
+        - name: ""
+          value: "empty"
+        - name: "Example/Header/1"
+          value: ":/"
+        - name: "example-header-2"
+          value: |
+            multi-line-header-value
+        set:
+        - name: ""
+          value: "empty"
+        - name: "Example:Header:3"
+          value: ":/"
+        - name: "example-header-4"
+          value: "  invalid"
+        remove:
+        - ""
     targetRef:
       group: gateway.networking.k8s.io
       kind: Gateway
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.out.yaml
index f11ce4f86..6d44a5f9e 100644
--- a/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.out.yaml
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers-error.out.yaml
@@ -26,6 +26,24 @@ clientTrafficPolicies:
         - name: example-header-4
           value: '  invalid'
       enableEnvoyHeaders: true
+      lateResponseHeaders:
+        add:
+        - name: ""
+          value: empty
+        - name: Example/Header/1
+          value: :/
+        - name: example-header-2
+          value: |
+            multi-line-header-value
+        remove:
+        - ""
+        set:
+        - name: ""
+          value: empty
+        - name: Example:Header:3
+          value: :/
+        - name: example-header-4
+          value: '  invalid'
       preserveXRequestID: true
       withUnderscoresAction: Allow
     targetRef:
@@ -49,7 +67,15 @@ clientTrafficPolicies:
           EarlyRequestHeaders cannot set a header with a '/' or ':' character in them. Header: '"Example:Header:3"'
           EarlyRequestHeaders cannot set a header with an invalid value. Header: '"example-header-4"'
           EarlyRequestHeaders cannot remove a header with an empty name
-          EarlyRequestHeaders did not provide valid configuration to add/set/remove any headers.
+          EarlyRequestHeaders did not provide valid configuration to add/set/remove any headers
+          LateResponseHeaders cannot add a header with an empty name
+          LateResponseHeaders cannot add a header with a '/' or ':' character in them. Header: '"Example/Header/1"'
+          LateResponseHeaders cannot add a header with an invalid value. Header: '"example-header-2"'
+          LateResponseHeaders cannot set a header with an empty name
+          LateResponseHeaders cannot set a header with a '/' or ':' character in them. Header: '"Example:Header:3"'
+          LateResponseHeaders cannot set a header with an invalid value. Header: '"example-header-4"'
+          LateResponseHeaders cannot remove a header with an empty name
+          LateResponseHeaders did not provide valid configuration to add/set/remove any headers.
         reason: Invalid
         status: "False"
         type: Accepted
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml
index cfb4bf0e2..24ecb52f8 100644
--- a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.in.yaml
@@ -23,6 +23,20 @@ clientTrafficPolicies:
         remove:
         - "my-removed-header"
         - "my-removed-header"
+      lateResponseHeaders:
+        add:
+        - name: "my-added-response-header"
+          value: "my-added-response-header-value"
+        - name: "my-added-response-header"
+          value: "my-added-response-header-value"
+        set:
+        - name: "my-set-response-header"
+          value: "my-set-response-header-value"
+        - name: "my-set-response-header"
+          value: "my-set-response-header-value"
+        remove:
+        - "my-removed-response-header"
+        - "my-removed-response-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 003b2ebdf..f4f23e72f 100644
--- a/internal/gatewayapi/testdata/clienttrafficpolicy-headers.out.yaml
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-headers.out.yaml
@@ -22,6 +22,20 @@ clientTrafficPolicies:
         - name: my-set-header
           value: my-set-header-value
       enableEnvoyHeaders: true
+      lateResponseHeaders:
+        add:
+        - name: my-added-response-header
+          value: my-added-response-header-value
+        - name: my-added-response-header
+          value: my-added-response-header-value
+        remove:
+        - my-removed-response-header
+        - my-removed-response-header
+        set:
+        - name: my-set-response-header
+          value: my-set-response-header-value
+        - name: my-set-response-header
+          value: my-set-response-header-value
       requestID: PreserveOrGenerate
       withUnderscoresAction: Allow
     targetRef:
@@ -175,6 +189,17 @@ xdsIR:
         earlyRemoveRequestHeaders:
         - my-removed-header
         enableEnvoyHeaders: true
+        lateAddResponseHeaders:
+        - append: true
+          name: my-added-response-header
+          value:
+          - my-added-response-header-value
+        - append: false
+          name: my-set-response-header
+          value:
+          - my-set-response-header-value
+        lateRemoveResponseHeaders:
+        - my-removed-response-header
         requestID: PreserveOrGenerate
         withUnderscoresAction: Allow
       hostnames:
@@ -205,6 +230,17 @@ xdsIR:
         earlyRemoveRequestHeaders:
         - my-removed-header
         enableEnvoyHeaders: true
+        lateAddResponseHeaders:
+        - append: true
+          name: my-added-response-header
+          value:
+          - my-added-response-header-value
+        - append: false
+          name: my-set-response-header
+          value:
+          - my-set-response-header-value
+        lateRemoveResponseHeaders:
+        - my-removed-response-header
         requestID: PreserveOrGenerate
         withUnderscoresAction: Allow
       hostnames:
diff --git a/internal/xds/translator/testdata/in/xds-ir/http-late-header-mutation.yaml b/internal/xds/translator/testdata/in/xds-ir/http-late-header-mutation.yaml
new file mode 100644
index 000000000..a9699d95d
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/http-late-header-mutation.yaml
@@ -0,0 +1,61 @@
+http:
+- name: "first-listener"
+  address: "::"
+  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: "first-route-dest/backend/0"
+- name: "second-listener"
+  address: "::"
+  port: 10081
+  hostnames:
+  - "*"
+  headers:
+    lateAddResponseHeaders:
+    - 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
+    lateRemoveResponseHeaders:
+    - "some-header5"
+    - "some-header6"
+  routes:
+  - name: "second-route"
+    hostname: "*"
+    destination:
+      name: "second-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.5"
+          port: 50000
+        name: "second-route-dest/backend/0"
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.clusters.yaml
new file mode 100644
index 000000000..535e3061d
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.clusters.yaml
@@ -0,0 +1,58 @@
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: first-route-dest
+  ignoreHealthOnHostRemoval: true
+  lbPolicy: LEAST_REQUEST
+  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
+  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: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: second-route-dest
+  ignoreHealthOnHostRemoval: true
+  lbPolicy: LEAST_REQUEST
+  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/http-late-header-mutation.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.endpoints.yaml
new file mode 100644
index 000000000..28a57caf3
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-late-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-late-header-mutation.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.listeners.yaml
new file mode 100644
index 000000000..7997ce458
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.listeners.yaml
@@ -0,0 +1,108 @@
+- address:
+    socketAddress:
+      address: '::'
+      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
+  maxConnectionsToAcceptPerSocketEvent: 1
+  name: first-listener
+  perConnectionBufferLimitBytes: 32768
+- address:
+    socketAddress:
+      address: '::'
+      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
+        http2ProtocolOptions:
+          initialConnectionWindowSize: 1048576
+          initialStreamWindowSize: 65536
+          maxConcurrentStreams: 100
+        httpFilters:
+        - name: envoy.filters.http.header_mutation
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.header_mutation.v3.HeaderMutation
+            mutations:
+              responseMutations:
+              - 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
+        - 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
+  maxConnectionsToAcceptPerSocketEvent: 1
+  name: second-listener
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http-late-header-mutation.routes.yaml
new file mode 100644
index 000000000..ff93cfff3
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-late-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 7413e61c5..92ce54ed8 100644
--- a/site/content/en/latest/api/extension_types.md
+++ b/site/content/en/latest/api/extension_types.md
@@ -1232,6 +1232,7 @@ _Appears in:_
 | `envoy.filters.http.compressor` | EnvoyFilterCompressor defines the Envoy HTTP compressor filter.<br /> | 
 | `envoy.filters.http.router` | EnvoyFilterRouter defines the Envoy HTTP router filter.<br /> | 
 | `envoy.filters.http.buffer` | EnvoyFilterBuffer defines the Envoy HTTP buffer filter<br /> | 
+| `envoy.filters.http.header_mutation` | EnvoyFilterHeaderMutation defines the Envoy HTTP header mutation filter<br /> | 
 
 
 #### EnvoyGateway
@@ -2521,6 +2522,7 @@ _Appears in:_
 | `preserveXRequestID` | _boolean_ |  false  |  | PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge<br />(Edge request is the request from external clients to front Envoy) and not reset it, which is the current Envoy behaviour.<br />Defaults to false and cannot be combined with RequestID.<br />Deprecated: use RequestID=Preserve instead |
 | `requestID` | _[RequestIDAction](#requestidaction)_ |  false  |  | RequestID configures Envoy's behavior for handling the `X-Request-ID` header.<br />Defaults to `Generate` and builds the `X-Request-ID` for every request and ignores pre-existing values from the edge.<br />(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<br />routing, tracing and built-in header manipulation. |
+| `lateResponseHeaders` | _[HTTPHeaderFilter](#httpheaderfilter)_ |  false  |  | LateResponseHeaders defines settings for global response header modification. |
 
 
 #### HealthCheck
diff --git a/site/content/en/latest/concepts/gateway_api_extensions/client-traffic-policy.md b/site/content/en/latest/concepts/gateway_api_extensions/client-traffic-policy.md
index b5afbfb06..563e1e68c 100644
--- a/site/content/en/latest/concepts/gateway_api_extensions/client-traffic-policy.md
+++ b/site/content/en/latest/concepts/gateway_api_extensions/client-traffic-policy.md
@@ -2,6 +2,7 @@
 title: "ClientTrafficPolicy"
 ---
 ## Before you Begin
+
 - [Gateway API Extensions](_index.md)
 
 ## Overview
@@ -129,6 +130,7 @@ spec:
 ```
 
 In this case:
+
 - Policy A will be applied/attached to the specific Listener defined in the `targetRef.SectionName`
 - Policy B will be applied to the remaining Listeners within the Gateway. Policy B will have an additional status condition Overridden=True.
 
@@ -136,6 +138,7 @@ In this case:
 
 - [Connection Limit](../../tasks/traffic/connection-limit.md)
 - [HTTP Request Headers](../../tasks/traffic/http-request-headers)
+- [HTTP Response Headers](../../tasks/traffic/http-response-headers)
 - [HTTP/3](../../tasks/traffic/http3)
 - [Mutual TLS: External Clients to the Gateway](../../tasks/security/mutual-tls/)
 - [ClientTrafficPolicy API Reference](../../api/extension_types#clienttrafficpolicy)
diff --git a/site/content/en/latest/tasks/observability/gateway-api-metadata.md b/site/content/en/latest/tasks/observability/gateway-api-metadata.md
new file mode 100644
index 000000000..999246a07
--- /dev/null
+++ b/site/content/en/latest/tasks/observability/gateway-api-metadata.md
@@ -0,0 +1,89 @@
+---
+title: "Gateway API Metadata"
+---
+
+## Background 
+Envoy Gateway translates Gateway API resources to Envoy XDS resources. In this translation process, Envoy Gateway annotates XDS resources with additional metadata from their origin Gateway API resources. 
+
+Gateway API Metadata includes:
+- K8s Resource Kinds, Names and Namespaces.
+- K8s Resource Annotations with the `gateway.envoyproxy.io/` prefix.
+- K8s Resource SectionNames (when applicable, e.g. for Route rules and Listeners).
+
+Gateway API Metadata is added to XDS resources using envoy's [Static Metadata][] under `metadata.filter_metadata.envoy-gateway.resources`. Currently, `resources` only contains the primary origin resource. 
+However, in the future, additional relevant resources (e.g. policies, filters attached to the primary origin resources) may be added. 
+
+## Supported Resources 
+Currently, the following mapping of Gateway API metadata to XDS metadata are supported:
+
+| XDS Resource     | Primary Gateway API Resource                           | XDS Metadata                                                                        | Comments
+|------------------|--------------------------------------------------------|-------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
+| [Virtual Host][] | `Gateway`                                              | Kind, Namespace, Name, Annotations, SectionName (`spec.listeners.<listener>.name`)  |                                                                                   |
+| [Route][]        | `HTTPRoute`, `GRPCRoute`                               | Kind, Namespace, Name, Annotations, SectionName (`spec.listener.rules.<rule>.name`) |                                                                                   | 
+| [Cluster][]      | `xRoute`                                               | Kind, Namespace, Name, Annotations, SectionName (`spec.listener.rules.<rule>.name`) |                                                                                   |
+| [Cluster][]      | `EnvoyProxy`, `EnvoyExtensionPolicy`, `SecurityPolicy` | Kind, Namespace, Name, Annotations, SectionName (`spec.listener.rules.<rule>.name`) |  When a non-xRoute BackendRef is used (e.g. ext_auth, observabiltiy sink, ... )   |
+| [LBEndpoints][]  | `Service`, `ServiceImport`, `Backend`                  | Kind, Namespace, Name, Annotations, SectionName (`backendRef.port`)                 |                                                                                   |
+
+For example, consider the following Gateway API HTTPRoute:
+
+```yaml
+kind: HTTPRoute
+apiVersion: gateway.networking.k8s.io/v1
+metadata:
+  annotations:
+    gateway.envoyproxy.io/foo: bar
+  name: myroute
+  namespace: myns
+spec:
+  rules:
+  - name: myrule
+    matches:
+    - path:
+        type: PathPrefix
+        value: /mypath
+```
+
+The translated XDS [Route][] contains Gateway API metadata under :
+
+```yaml
+name: httproute/myns/myroute/rule/0/match/0/*
+match:
+  path_separated_prefix: "/mypath"
+route:
+  cluster: httproute/myns/myroute/rule/0
+metadata:
+  filter_metadata:
+    envoy-gateway:
+      resources:
+      - namespace: myns
+        kind: HTTPRoute
+        annotations:
+          foo: bar
+        name: myroute
+        sectionName: myrule
+```
+
+## Use Cases
+XDS Metadata serves multiple purposes:
+- Observability: Envoy proxy access logs can be [enriched with Gateway-API resource](./proxy-accesslog.md) context and custom annotations, creating an association with relevant Application Developers personas.
+- Troubleshooting: users and tools that analyze the envoy proxy XDS config can identify the Gateway API resources that lead to the XDS configuration's creation.
+- Extensibility: 
+  - Envoy Gateway [Extension Servers][] can leverage Gateway API metadata as additional context annotating XDS resources sent for mutation.  
+  - Envoy Proxy extensions can leverage XDS metadata as additional context when processing traffic:
+    - [lua][] extensions can access metadata using the [Metadata Stream handle API][] .
+    - [ext_proc][] extensions can access metadata using the `xds.*_metadata` [ext_proc attribute][]. 
+  
+
+[Static Metadata]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/core/v3/base.proto#envoy-v3-api-msg-config-core-v3-metadata
+[Virtual Host]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-virtualhost
+[Route]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-msg-config-route-v3-route
+[Cluster]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto.html
+[LBEndpoints]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/endpoint/v3/endpoint_components.proto#envoy-v3-api-msg-config-endpoint-v3-lbendpoint
+[Metadata Stream handle API]: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter#metadata
+[lua]: ../../api/extension_types#lua
+[ext_proc]: ../../api/extension_types#extproc
+[ext_proc attribute]: ../../api/extension_types#processingmodeoptions
+[Extension Servers]: ../extensibility/extension-server.md
+
+
+
diff --git a/site/content/en/latest/tasks/observability/proxy-accesslog.md b/site/content/en/latest/tasks/observability/proxy-accesslog.md
index cad48129e..b26b880a2 100644
--- a/site/content/en/latest/tasks/observability/proxy-accesslog.md
+++ b/site/content/en/latest/tasks/observability/proxy-accesslog.md
@@ -239,12 +239,15 @@ curl -s "http://$LOKI_IP:3100/loki/api/v1/query_range" --data-urlencode "query={
 ```
 
 
-## Additional Metadata
+## Gateway API Metadata
 
 Envoy Gateway provides additional metadata about the K8s resources that were translated to  certain envoy resources.
-For example, details about the `HTTPRoute` and `GRPCRoute` (kind, group, name, namespace and annotations) are available
-for access log formatter using the `METADATA` operator. To enrich logs, users can add log operator such as:
-`%METADATA(ROUTE:envoy-gateway:resources)%` to their access log format. 
+For example, details about the `HTTPRoute` and `GRPCRoute` (kind, group, name, namespace and annotations) are available 
+for access log formatter using the `METADATA` operator. 
+
+To enrich logs, users can add log operator that refer to XDS metadata, such as:
+- `%METADATA(ROUTE:envoy-gateway:resources)%` 
+- `%CEL(xds.route_metadata.filter_metadata['envoy-gateway']['resources'][0]['name'])%` 
 
 ## Access Log Types
 
diff --git a/site/content/en/latest/tasks/traffic/http-response-headers.md b/site/content/en/latest/tasks/traffic/http-response-headers.md
index 5f80b6625..9d35e1eaa 100644
--- a/site/content/en/latest/tasks/traffic/http-response-headers.md
+++ b/site/content/en/latest/tasks/traffic/http-response-headers.md
@@ -440,6 +440,164 @@ spec:
 {{% /tab %}}
 {{< /tabpane >}}
 
+## Late Header Modification
+
+In some cases it may be necessary to modify response headers globally. For example, you may want to add a security header (such as `Strict-Transport-Security`) to all routes. Envoy Gateway supports this functionality using the [ClientTrafficPolicy][] API.
+
+A [ClientTrafficPolicy][] resource can be attached to a [Gateway][] resource to configure late header modification for all its routes. In the following example we will demonstrate how late header modification can be configured.
+
+{{< tabpane text=true >}}
+{{% tab header="Apply from stdin" %}}
+
+```shell
+cat <<EOF | kubectl apply -f -
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-headers
+spec:
+  parentRefs:
+    - name: eg
+  hostnames:
+    - headers.example
+  rules:
+    - matches:
+      - path:
+          type: PathPrefix
+          value: /
+      backendRefs:
+        - group: ""
+          kind: Service
+          name: backend
+          port: 3000
+          weight: 1
+      filters:
+        - type: ResponseHeaderModifier
+          responseHeaderModifier:
+            add:
+              - name: late-added-header
+                value: filter
+              - name: late-set-header
+                value: filter
+              - name: late-removed-header
+                value: filter
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: ClientTrafficPolicy
+metadata:
+  name: enable-late-headers
+  namespace: default
+spec:
+  targetRef:
+    group: gateway.networking.k8s.io
+    kind: Gateway
+    name: eg
+  headers:
+    lateResponseHeaders:
+      add:
+        - name: "late-added-header"
+          value: "late"
+      set:
+        - name: "late-set-header"
+          value: "late"
+      remove:
+        - "late-removed-header"
+EOF
+```
+
+{{% /tab %}}
+{{% tab header="Apply from file" %}}
+Save and apply the following resource to your cluster:
+
+```yaml
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-headers
+spec:
+  parentRefs:
+    - name: eg
+  hostnames:
+    - headers.example
+  rules:
+    - matches:
+        - path:
+            type: PathPrefix
+            value: /
+      backendRefs:
+        - group: ""
+          kind: Service
+          name: backend
+          port: 3000
+          weight: 1
+      filters:
+        - type: ResponseHeaderModifier
+          responseHeaderModifier:
+            add:
+              - name: late-added-header
+                value: filter
+              - name: late-set-header
+                value: filter
+              - name: late-removed-header
+                value: filter
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: ClientTrafficPolicy
+metadata:
+  name: enable-late-headers
+  namespace: default
+spec:
+  targetRef:
+    group: gateway.networking.k8s.io
+    kind: Gateway
+    name: eg
+  headers:
+    lateResponseHeaders:
+      add:
+        - name: "late-added-header"
+          value: "late"
+      set:
+        - name: "late-set-header"
+          value: "late"
+      remove:
+        - "late-removed-header"
+```
+
+{{% /tab %}}
+{{< /tabpane >}}
+
+Querying `headers.example/get` should result in a `200` response from the example `Gateway` with the following headers:
+
+- `late-added-header` contains backend (example app), filter (RouteFilter), and late (ClientTrafficPolicy) values.
+- `late-set-header` contains only the late value, since the ClientTrafficPolicy overwrote all others.
+- `late-removed-header` is missing entirely - again, due to the ClientTrafficPolicy.
+
+```console
+$ curl -vvv "http://${GATEWAY_HOST}/get" \
+  --header "Host: headers.example" \
+  --header "X-Echo-Set-Header: late-added-header:backend,late-set-header:backend,late-removed-header:backend"
+
+...
+> GET /get HTTP/1.1
+> Host: headers.example
+> User-Agent: curl/7.81.0
+> Accept: */*
+> X-Echo-Set-Header: late-added-header:backend,late-set-header:backend,late-removed-header:backend
+>
+
+< HTTP/1.1 200 OK
+< content-type: application/json
+< late-added-header: backend
+< late-added-header: filter
+< late-added-header: late
+< late-set-header: late
+<
+...
+```
+
 [HTTPRoute]: https://gateway-api.sigs.k8s.io/api-types/httproute/
 [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
+[Gateway]: https://gateway-api.sigs.k8s.io/api-types/gateway/
+[ClientTrafficPolicy]: ../../../api/extension_types#clienttrafficpolicy
diff --git a/test/e2e/testdata/header-settings.yaml b/test/e2e/testdata/header-settings.yaml
index dab686f29..574ec9e04 100644
--- a/test/e2e/testdata/header-settings.yaml
+++ b/test/e2e/testdata/header-settings.yaml
@@ -18,6 +18,15 @@ spec:
           value: "early"
       remove:
         - "early-removed-header"
+    lateResponseHeaders:
+      add:
+        - name: "late-added-header"
+          value: "late"
+      set:
+        - name: "late-set-header"
+          value: "late"
+      remove:
+        - "late-removed-header"
 ---
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
@@ -45,3 +54,30 @@ spec:
       backendRefs:
         - name: infra-backend-v1
           port: 8080
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-with-late-headers
+  namespace: gateway-conformance-infra
+spec:
+  parentRefs:
+    - name: same-namespace
+  rules:
+    - matches:
+        - path:
+            type: PathPrefix
+            value: /late-header
+      filters:
+        - type: ResponseHeaderModifier
+          responseHeaderModifier:
+            add:
+              - name: late-added-header
+                value: filter
+              - name: late-set-header
+                value: filter
+              - name: late-removed-header
+                value: filter
+      backendRefs:
+        - name: infra-backend-v1
+          port: 8080
diff --git a/test/e2e/testdata/response-override.yaml b/test/e2e/testdata/response-override.yaml
index f4a5ebb93..8c45a2992 100644
--- a/test/e2e/testdata/response-override.yaml
+++ b/test/e2e/testdata/response-override.yaml
@@ -66,3 +66,15 @@ spec:
             group: ""
             kind: ConfigMap
             name: response-override-config
+    - match:
+        statusCodes:
+          - type: Value
+            value: 401
+      redirect:
+        scheme: https
+        hostname: www.redirect.com
+        port: 8443
+        statusCode: 301
+        path:
+          type: ReplaceFullPath
+          replaceFullPath: /status/301
diff --git a/test/e2e/tests/header_settings.go b/test/e2e/tests/header_settings.go
index 699f6baa9..9c9f671ba 100644
--- a/test/e2e/tests/header_settings.go
+++ b/test/e2e/tests/header_settings.go
@@ -71,5 +71,44 @@ var HeaderSettingsTest = suite.ConformanceTest{
 
 			http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expected)
 		})
+
+		t.Run("Late header modifications should apply", func(t *testing.T) {
+			ns := "gateway-conformance-infra"
+			routeNN := types.NamespacedName{Name: "http-with-late-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(resource.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: "/late-header",
+				},
+				BackendSetResponseHeaders: map[string]string{
+					"late-added-header":   "backend",
+					"late-set-header":     "backend",
+					"late-removed-header": "backend",
+				},
+				Response: http.Response{
+					StatusCode: 200,
+					Headers: map[string]string{
+						"late-added-header": "backend,filter,late",
+						"late-set-header":   "late",
+					},
+					AbsentHeaders: []string{
+						"late-removed-header",
+					},
+				},
+				Namespace: ns,
+			}
+
+			http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expected)
+		})
 	},
 }
diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go
index 4478b43ce..22bddfd23 100644
--- a/test/e2e/tests/response-override.go
+++ b/test/e2e/tests/response-override.go
@@ -52,6 +52,7 @@ var ResponseOverrideTest = suite.ConformanceTest{
 			verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/status/404", "text/plain", "404 Oops! Your request is not found.", 404)
 			verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/status/500", "application/json", `{"error": "Internal Server Error"}`, 500)
 			verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/status/403", "", "", 404)
+			verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/status/401", "", "", 301)
 		})
 	},
 }
diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml
index 214177abd..6ad37ddbf 100644
--- a/test/helm/gateway-crds-helm/all.out.yaml
+++ b/test/helm/gateway-crds-helm/all.out.yaml
@@ -20334,6 +20334,136 @@ spec:
                       EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
                       and responses.
                     type: boolean
+                  lateResponseHeaders:
+                    description: LateResponseHeaders defines settings for global response
+                      header modification.
+                    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: 64
+                        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: 64
+                        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: 64
+                        type: array
+                        x-kubernetes-list-map-keys:
+                        - name
+                        x-kubernetes-list-type: map
+                    type: object
                   preserveXRequestID:
                     description: |-
                       PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge
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 a5fa08c58..87ff10eba 100644
--- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
+++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
@@ -3022,6 +3022,136 @@ spec:
                       EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
                       and responses.
                     type: boolean
+                  lateResponseHeaders:
+                    description: LateResponseHeaders defines settings for global response
+                      header modification.
+                    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: 64
+                        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: 64
+                        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: 64
+                        type: array
+                        x-kubernetes-list-map-keys:
+                        - name
+                        x-kubernetes-list-type: map
+                    type: object
                   preserveXRequestID:
                     description: |-
                       PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge