Repository: envoyproxy/gateway
Pull request #2512 (2024-01-26 23:25:06)

bug: fix printable for ir xds

zirain: can you share more details about this? I think related to security?
arkodg: > can you share more details about this? I think related to security?

yes was added in https://github.com/envoyproxy/gateway/pull/607, but recently broke
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) Report
Attention: `10 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`e168ef9`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/e168ef961dbd40861af401bf70c781e2cba282fd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) 64.62% compared to head [(`d008806`)](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) 64.71%.
> Report is 3 commits behind head on main.

| [Files](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) | Patch % | Lines |
|---|---|---|
| [internal/ir/xds.go](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy#diff-aW50ZXJuYWwvaXIveGRzLmdv) | 44.44% | [5 Missing :warning: ](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) |
| [internal/ir/infra.go](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy#diff-aW50ZXJuYWwvaXIvaW5mcmEuZ28=) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) |
| [internal/gatewayapi/runner/runner.go](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy#diff-aW50ZXJuYWwvZ2F0ZXdheWFwaS9ydW5uZXIvcnVubmVyLmdv) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2512      +/-   ##
==========================================
+ Coverage   64.62%   64.71%   +0.09%     
==========================================
  Files         115      116       +1     
  Lines       17513    17581      +68     
==========================================
+ Hits        11318    11378      +60     
- Misses       5470     5477       +7     
- Partials      725      726       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/envoyproxy/gateway/pull/2512?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).


Patch:
diff --git a/api/v1alpha1/envoypatchpolicy_types.go b/api/v1alpha1/envoypatchpolicy_types.go
index 5217afce9..bec633692 100644
--- a/api/v1alpha1/envoypatchpolicy_types.go
+++ b/api/v1alpha1/envoypatchpolicy_types.go
@@ -158,6 +158,10 @@ const (
 	// PolicyReasonTargetNotFound is used with the "Programmed" condition when the
 	// policy cannot find the resource type to patch to.
 	PolicyReasonResourceNotFound gwapiv1a2.PolicyConditionReason = "ResourceNotFound"
+
+	// PolicyReasonDisabled is used with the "Accepted" condition when the policy
+	// feature is disabled by the configuration.
+	PolicyReasonDisabled gwapiv1a2.PolicyConditionReason = "Disabled"
 )
 
 //+kubebuilder:object:root=true
diff --git a/internal/cmd/egctl/translate.go b/internal/cmd/egctl/translate.go
index 47ab944f4..179fe2cac 100644
--- a/internal/cmd/egctl/translate.go
+++ b/internal/cmd/egctl/translate.go
@@ -265,6 +265,7 @@ func translateGatewayAPIToGatewayAPI(resources *gatewayapi.Resources) (gatewayap
 		GatewayClassName:        gwapiv1.ObjectName(resources.GatewayClass.Name),
 		GlobalRateLimitEnabled:  true,
 		EndpointRoutingDisabled: true,
+		EnvoyPatchPolicyEnabled: true,
 	}
 	gRes := gTranslator.Translate(resources)
 	// Update the status of the GatewayClass based on EnvoyProxy validation
@@ -296,6 +297,7 @@ func translateGatewayAPIToXds(dnsDomain string, resourceType string, resources *
 		GatewayClassName:        gwapiv1.ObjectName(resources.GatewayClass.Name),
 		GlobalRateLimitEnabled:  true,
 		EndpointRoutingDisabled: true,
+		EnvoyPatchPolicyEnabled: true,
 	}
 	gRes := gTranslator.Translate(resources)
 
diff --git a/internal/gatewayapi/clienttrafficpolicy.go b/internal/gatewayapi/clienttrafficpolicy.go
index 83d38cd5a..0ee2c28a4 100644
--- a/internal/gatewayapi/clienttrafficpolicy.go
+++ b/internal/gatewayapi/clienttrafficpolicy.go
@@ -386,7 +386,8 @@ func translateHTTP1Settings(http1Settings *egv1a1.HTTP1Settings, httpIR *ir.HTTP
 		return
 	}
 	httpIR.HTTP1 = &ir.HTTP1Settings{
-		EnableTrailers: ptr.Deref(http1Settings.EnableTrailers, false),
+		EnableTrailers:     ptr.Deref(http1Settings.EnableTrailers, false),
+		PreserveHeaderCase: ptr.Deref(http1Settings.PreserveHeaderCase, false),
 	}
 }
 
diff --git a/internal/gatewayapi/envoypatchpolicy.go b/internal/gatewayapi/envoypatchpolicy.go
index c3d660ecc..2b3025d80 100644
--- a/internal/gatewayapi/envoypatchpolicy.go
+++ b/internal/gatewayapi/envoypatchpolicy.go
@@ -94,6 +94,16 @@ func (t *Translator) ProcessEnvoyPatchPolicies(envoyPatchPolicies []*egv1a1.Envo
 			continue
 		}
 
+		if !t.EnvoyPatchPolicyEnabled {
+			status.SetEnvoyPatchPolicyCondition(policy,
+				gwv1a2.PolicyConditionAccepted,
+				metav1.ConditionFalse,
+				egv1a1.PolicyReasonDisabled,
+				"EnvoyPatchPolicy is disabled in the EnvoyGateway configuration",
+			)
+			continue
+		}
+
 		// Save the patch
 		for _, patch := range policy.Spec.JSONPatches {
 			irPatch := ir.JSONPatchConfig{}
diff --git a/internal/gatewayapi/runner/runner.go b/internal/gatewayapi/runner/runner.go
index 835ce10fd..b930b887a 100644
--- a/internal/gatewayapi/runner/runner.go
+++ b/internal/gatewayapi/runner/runner.go
@@ -10,7 +10,6 @@ import (
 
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
-	"sigs.k8s.io/yaml"
 
 	"github.com/envoyproxy/gateway/api/v1alpha1"
 	"github.com/envoyproxy/gateway/internal/envoygateway/config"
@@ -61,9 +60,10 @@ func (r *Runner) subscribeAndTranslate(ctx context.Context) {
 
 			// Translate and publish IRs.
 			t := &gatewayapi.Translator{
-				GatewayControllerName:  r.Server.EnvoyGateway.Gateway.ControllerName,
-				GatewayClassName:       v1.ObjectName(update.Key),
-				GlobalRateLimitEnabled: r.EnvoyGateway.RateLimit != nil,
+				GatewayControllerName:   r.Server.EnvoyGateway.Gateway.ControllerName,
+				GatewayClassName:        v1.ObjectName(update.Key),
+				GlobalRateLimitEnabled:  r.EnvoyGateway.RateLimit != nil,
+				EnvoyPatchPolicyEnabled: r.EnvoyGateway.ExtensionAPIs != nil && r.EnvoyGateway.ExtensionAPIs.EnableEnvoyPatchPolicy,
 			}
 
 			// If an extension is loaded, pass its supported groups/kinds to the translator
@@ -77,11 +77,6 @@ func (r *Runner) subscribeAndTranslate(ctx context.Context) {
 			// Translate to IR
 			result := t.Translate(val)
 
-			yamlXdsIR, _ := yaml.Marshal(&result.XdsIR)
-			r.Logger.WithValues("output", "xds-ir").Info(string(yamlXdsIR))
-			yamlInfraIR, _ := yaml.Marshal(&result.InfraIR)
-			r.Logger.WithValues("output", "infra-ir").Info(string(yamlInfraIR))
-
 			var curKeys, newKeys []string
 			// Get current IR keys
 			for key := range r.InfraIR.LoadAll() {
@@ -91,6 +86,7 @@ func (r *Runner) subscribeAndTranslate(ctx context.Context) {
 			// Publish the IRs.
 			// Also validate the ir before sending it.
 			for key, val := range result.InfraIR {
+				r.Logger.WithValues("infra-ir", key).Info(val.YAMLString())
 				if err := val.Validate(); err != nil {
 					r.Logger.Error(err, "unable to validate infra ir, skipped sending it")
 					errChan <- err
@@ -101,6 +97,7 @@ func (r *Runner) subscribeAndTranslate(ctx context.Context) {
 			}
 
 			for key, val := range result.XdsIR {
+				r.Logger.WithValues("xds-ir", key).Info(val.YAMLString())
 				if err := val.Validate(); err != nil {
 					r.Logger.Error(err, "unable to validate xds ir, skipped sending it")
 					errChan <- err
diff --git a/internal/gatewayapi/translator.go b/internal/gatewayapi/translator.go
index 2783b22d0..fbbd04900 100644
--- a/internal/gatewayapi/translator.go
+++ b/internal/gatewayapi/translator.go
@@ -81,6 +81,10 @@ type Translator struct {
 	// should be merged under the parent GatewayClass.
 	MergeGateways bool
 
+	// EnvoyPatchPolicyEnabled when the EnvoyPatchPolicy
+	// feature is enabled.
+	EnvoyPatchPolicyEnabled bool
+
 	// ExtensionGroupKinds stores the group/kind for all resources
 	// introduced by an Extension so that the translator can
 	// store referenced resources in the IR for later use.
diff --git a/internal/ir/infra.go b/internal/ir/infra.go
index 278135736..6c552b8ff 100644
--- a/internal/ir/infra.go
+++ b/internal/ir/infra.go
@@ -13,6 +13,7 @@ import (
 
 	"golang.org/x/exp/slices"
 	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+	"sigs.k8s.io/yaml"
 
 	"github.com/envoyproxy/gateway/api/v1alpha1"
 )
@@ -28,6 +29,11 @@ type Infra struct {
 	Proxy *ProxyInfra `json:"proxy" yaml:"proxy"`
 }
 
+func (i Infra) YAMLString() string {
+	y, _ := yaml.Marshal(&i)
+	return string(y)
+}
+
 // ProxyInfra defines managed proxy infrastructure.
 // +k8s:deepcopy-gen=true
 type ProxyInfra struct {
@@ -76,7 +82,8 @@ type HTTP3Settings struct {
 // HTTP1Settings provides HTTP/1 configuration on the listener.
 // +k8s:deepcopy-gen=true
 type HTTP1Settings struct {
-	EnableTrailers bool `json:"enableTrailers,omitempty" yaml:"enableTrailers,omitempty"`
+	EnableTrailers     bool `json:"enableTrailers,omitempty" yaml:"enableTrailers,omitempty"`
+	PreserveHeaderCase bool `json:"preserveHeaderCase,omitempty" yaml:"preserveHeaderCase,omitempty"`
 }
 
 // ListenerPort defines a network port of a listener.
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index 542831110..fa03a6aef 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -13,11 +13,11 @@ import (
 	"reflect"
 
 	"golang.org/x/exp/slices"
-
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/util/validation"
+	"sigs.k8s.io/yaml"
 
 	egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
 	egv1a1validation "github.com/envoyproxy/gateway/api/v1alpha1/validation"
@@ -57,6 +57,8 @@ var (
 	ErrHCHTTPExpectedStatusesInvalid        = errors.New("field HTTPHealthChecker.ExpectedStatuses should be specified")
 	ErrHealthCheckPayloadInvalid            = errors.New("one of Text, Binary fields must be set in payload")
 	ErrHTTPStatusInvalid                    = errors.New("HTTPStatus should be in [200,600)")
+
+	redacted = []byte("[redacted]")
 )
 
 // Xds holds the intermediate representation of a Gateway and is
@@ -155,20 +157,29 @@ func (x Xds) GetUDPListener(name string) *UDPListener {
 	return nil
 }
 
+func (x Xds) YAMLString() string {
+	y, _ := yaml.Marshal(x.Printable())
+	return string(y)
+}
+
 // Printable returns a deep copy of the resource that can be safely logged.
 func (x Xds) Printable() *Xds {
 	out := x.DeepCopy()
 	for _, listener := range out.HTTP {
 		// Omit field
-		listener.TLS = nil
+		if listener.TLS != nil {
+			for i := range listener.TLS.Certificates {
+				listener.TLS.Certificates[i].PrivateKey = redacted
+			}
+		}
 
 		for _, route := range listener.Routes {
 			// Omit field
 			if route.OIDC != nil {
-				route.OIDC.ClientSecret = []byte{}
+				route.OIDC.ClientSecret = redacted
 			}
 			if route.BasicAuth != nil {
-				route.BasicAuth.Users = []byte{}
+				route.BasicAuth.Users = redacted
 			}
 		}
 	}
diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go
index 0e1ae2500..419d13edc 100644
--- a/internal/provider/kubernetes/controller.go
+++ b/internal/provider/kubernetes/controller.go
@@ -268,19 +268,18 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 	}
 
 	// Add all EnvoyPatchPolicies
-	if r.envoyGateway.ExtensionAPIs != nil && r.envoyGateway.ExtensionAPIs.EnableEnvoyPatchPolicy {
-		envoyPatchPolicies := v1alpha1.EnvoyPatchPolicyList{}
-		if err := r.client.List(ctx, &envoyPatchPolicies); err != nil {
-			return reconcile.Result{}, fmt.Errorf("error listing EnvoyPatchPolicies: %w", err)
-		}
+	envoyPatchPolicies := v1alpha1.EnvoyPatchPolicyList{}
+	if err := r.client.List(ctx, &envoyPatchPolicies); err != nil {
+		return reconcile.Result{}, fmt.Errorf("error listing EnvoyPatchPolicies: %w", err)
+	}
 
-		for _, policy := range envoyPatchPolicies.Items {
-			policy := policy
-			// Discard Status to reduce memory consumption in watchable
-			// It will be recomputed by the gateway-api layer
-			policy.Status = v1alpha1.EnvoyPatchPolicyStatus{}
-			resourceTree.EnvoyPatchPolicies = append(resourceTree.EnvoyPatchPolicies, &policy)
-		}
+	for _, policy := range envoyPatchPolicies.Items {
+		policy := policy
+		// Discard Status to reduce memory consumption in watchable
+		// It will be recomputed by the gateway-api layer
+		policy.Status = v1alpha1.EnvoyPatchPolicyStatus{}
+
+		resourceTree.EnvoyPatchPolicies = append(resourceTree.EnvoyPatchPolicies, &policy)
 	}
 
 	// Add all ClientTrafficPolicies
diff --git a/internal/xds/translator/cluster.go b/internal/xds/translator/cluster.go
index f3a6eb242..74cb01c91 100644
--- a/internal/xds/translator/cluster.go
+++ b/internal/xds/translator/cluster.go
@@ -14,6 +14,7 @@ import (
 	clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
 	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
+	preservecasev3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/header_formatters/preserve_case/v3"
 	proxyprotocolv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/proxy_protocol/v3"
 	rawbufferv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/raw_buffer/v3"
 	httpv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/upstreams/http/v3"
@@ -23,6 +24,7 @@ import (
 	"google.golang.org/protobuf/types/known/anypb"
 	"google.golang.org/protobuf/types/known/durationpb"
 	"google.golang.org/protobuf/types/known/wrapperspb"
+	"k8s.io/utils/ptr"
 
 	"github.com/envoyproxy/gateway/internal/ir"
 )
@@ -42,7 +44,7 @@ type xdsClusterArgs struct {
 	proxyProtocol  *ir.ProxyProtocol
 	circuitBreaker *ir.CircuitBreaker
 	healthCheck    *ir.HealthCheck
-	enableTrailers bool
+	http1Settings  *ir.HTTP1Settings
 }
 
 type EndpointType int
@@ -96,7 +98,8 @@ func buildXdsCluster(args *xdsClusterArgs) *clusterv3.Cluster {
 			break
 		}
 	}
-	cluster.TypedExtensionProtocolOptions = buildTypedExtensionProtocolOptions(isHTTP2, args.enableTrailers)
+	cluster.TypedExtensionProtocolOptions = buildTypedExtensionProtocolOptions(isHTTP2,
+		ptr.Deref(args.http1Settings, ir.HTTP1Settings{}))
 
 	// Set Load Balancer policy
 	//nolint:gocritic
@@ -314,44 +317,51 @@ func buildXdsClusterLoadAssignment(clusterName string, destSettings []*ir.Destin
 	return &endpointv3.ClusterLoadAssignment{ClusterName: clusterName, Endpoints: localities}
 }
 
-func buildTypedExtensionProtocolOptions(http2, http1Trailers bool) map[string]*anypb.Any {
+func buildTypedExtensionProtocolOptions(http2 bool, http1Opts ir.HTTP1Settings) map[string]*anypb.Any {
+	if !http2 && !http1Opts.EnableTrailers && !http1Opts.PreserveHeaderCase {
+		return nil
+	}
 	var anyProtocolOptions *anypb.Any
 
+	protocolOptions := httpv3.HttpProtocolOptions{}
 	if http2 {
-		protocolOptions := httpv3.HttpProtocolOptions{
-			UpstreamProtocolOptions: &httpv3.HttpProtocolOptions_ExplicitHttpConfig_{
-				ExplicitHttpConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig{
-					ProtocolConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig_Http2ProtocolOptions{},
-				},
+		protocolOptions.UpstreamProtocolOptions = &httpv3.HttpProtocolOptions_ExplicitHttpConfig_{
+			ExplicitHttpConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig{
+				ProtocolConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig_Http2ProtocolOptions{},
 			},
 		}
-
-		anyProtocolOptions, _ = anypb.New(&protocolOptions)
-	} else if http1Trailers {
-		// TODO: If the cluster is TLS enabled, use AutoHTTPConfig instead of ExplicitHttpConfig
-		// so that when ALPN is supported enabling trailers doesn't force HTTP/1.1
-		protocolOptions := httpv3.HttpProtocolOptions{
-			UpstreamProtocolOptions: &httpv3.HttpProtocolOptions_ExplicitHttpConfig_{
-				ExplicitHttpConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig{
-					ProtocolConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig_HttpProtocolOptions{
-						HttpProtocolOptions: &corev3.Http1ProtocolOptions{
-							EnableTrailers: http1Trailers,
-						},
+	} else if http1Opts.EnableTrailers || http1Opts.PreserveHeaderCase {
+		opts := &corev3.Http1ProtocolOptions{
+			EnableTrailers: http1Opts.EnableTrailers,
+		}
+		if http1Opts.PreserveHeaderCase {
+			preservecaseAny, _ := anypb.New(&preservecasev3.PreserveCaseFormatterConfig{})
+			opts.HeaderKeyFormat = &corev3.Http1ProtocolOptions_HeaderKeyFormat{
+				HeaderFormat: &corev3.Http1ProtocolOptions_HeaderKeyFormat_StatefulFormatter{
+					StatefulFormatter: &corev3.TypedExtensionConfig{
+						Name:        "preserve_case",
+						TypedConfig: preservecaseAny,
 					},
 				},
+			}
+		}
+		// TODO: If the cluster is TLS enabled, use AutoHTTPConfig instead of ExplicitHttpConfig
+		// so that when ALPN is supported setting HTTP/1.1 options doesn't force HTTP/1.1
+		protocolOptions.UpstreamProtocolOptions = &httpv3.HttpProtocolOptions_ExplicitHttpConfig_{
+			ExplicitHttpConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig{
+				ProtocolConfig: &httpv3.HttpProtocolOptions_ExplicitHttpConfig_HttpProtocolOptions{
+					HttpProtocolOptions: opts,
+				},
 			},
 		}
-		anyProtocolOptions, _ = anypb.New(&protocolOptions)
 	}
+	anyProtocolOptions, _ = anypb.New(&protocolOptions)
 
-	if anyProtocolOptions != nil {
-		extensionOptions := map[string]*anypb.Any{
-			extensionOptionsKey: anyProtocolOptions,
-		}
-
-		return extensionOptions
+	extensionOptions := map[string]*anypb.Any{
+		extensionOptionsKey: anyProtocolOptions,
 	}
-	return nil
+
+	return extensionOptions
 }
 
 // buildClusterName returns a cluster name for the given `host` and `port`.
diff --git a/internal/xds/translator/listener.go b/internal/xds/translator/listener.go
index e3ad6cadc..5cde4d7a7 100644
--- a/internal/xds/translator/listener.go
+++ b/internal/xds/translator/listener.go
@@ -16,6 +16,7 @@ import (
 	hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
 	tcpv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/tcp_proxy/v3"
 	udpv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/udp/udp_proxy/v3"
+	preservecasev3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/header_formatters/preserve_case/v3"
 	quicv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/quic/v3"
 	tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
 	"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
@@ -44,12 +45,24 @@ func http1ProtocolOptions(opts *ir.HTTP1Settings) *corev3.Http1ProtocolOptions {
 	if opts == nil {
 		return nil
 	}
-	if opts.EnableTrailers {
-		return &corev3.Http1ProtocolOptions{
-			EnableTrailers: opts.EnableTrailers,
+	if !opts.EnableTrailers && !opts.PreserveHeaderCase {
+		return nil
+	}
+	r := &corev3.Http1ProtocolOptions{
+		EnableTrailers: opts.EnableTrailers,
+	}
+	if opts.PreserveHeaderCase {
+		preservecaseAny, _ := anypb.New(&preservecasev3.PreserveCaseFormatterConfig{})
+		r.HeaderKeyFormat = &corev3.Http1ProtocolOptions_HeaderKeyFormat{
+			HeaderFormat: &corev3.Http1ProtocolOptions_HeaderKeyFormat_StatefulFormatter{
+				StatefulFormatter: &corev3.TypedExtensionConfig{
+					Name:        "preserve_case",
+					TypedConfig: preservecaseAny,
+				},
+			},
 		}
 	}
-	return nil
+	return r
 }
 
 func http2ProtocolOptions() *corev3.Http2ProtocolOptions {
diff --git a/internal/xds/translator/translator.go b/internal/xds/translator/translator.go
index ab10da49c..3eb87eb6b 100644
--- a/internal/xds/translator/translator.go
+++ b/internal/xds/translator/translator.go
@@ -20,7 +20,6 @@ import (
 	matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
 	resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3"
 	"google.golang.org/protobuf/types/known/wrapperspb"
-	"k8s.io/utils/ptr"
 
 	extensionTypes "github.com/envoyproxy/gateway/internal/extension/types"
 	"github.com/envoyproxy/gateway/internal/ir"
@@ -260,8 +259,7 @@ func (t *Translator) processHTTPListenerXdsTranslation(
 			vHost.Routes = append(vHost.Routes, xdsRoute)
 
 			if httpRoute.Destination != nil {
-				trailers := ptr.Deref(httpListener.HTTP1, ir.HTTP1Settings{}).EnableTrailers
-				if err = processXdsCluster(tCtx, httpRoute, trailers); err != nil {
+				if err = processXdsCluster(tCtx, httpRoute, httpListener.HTTP1); err != nil {
 					errs = errors.Join(errs, err)
 				}
 			}
@@ -486,7 +484,7 @@ func findXdsEndpoint(tCtx *types.ResourceVersionTable, name string) *endpointv3.
 }
 
 // processXdsCluster processes a xds cluster by its endpoint address type.
-func processXdsCluster(tCtx *types.ResourceVersionTable, httpRoute *ir.HTTPRoute, enableTrailers bool) error {
+func processXdsCluster(tCtx *types.ResourceVersionTable, httpRoute *ir.HTTPRoute, http1Settings *ir.HTTP1Settings) error {
 	// Get endpoint address type for xds cluster by returning the first DestinationSetting's AddressType,
 	// since there's no Mixed AddressType among all the DestinationSettings.
 	addrTypeState := httpRoute.Destination.Settings[0].AddressType
@@ -507,7 +505,7 @@ func processXdsCluster(tCtx *types.ResourceVersionTable, httpRoute *ir.HTTPRoute
 		proxyProtocol:  httpRoute.ProxyProtocol,
 		circuitBreaker: httpRoute.CircuitBreaker,
 		healthCheck:    httpRoute.HealthCheck,
-		enableTrailers: enableTrailers,
+		http1Settings:  http1Settings,
 	}); err != nil && !errors.Is(err, ErrXdsClusterExists) {
 		return err
 	}


Test patch:
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-preserve-case.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-preserve-case.in.yaml
new file mode 100644
index 000000000..5a5711f32
--- /dev/null
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-preserve-case.in.yaml
@@ -0,0 +1,36 @@
+clientTrafficPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: ClientTrafficPolicy
+  metadata:
+    namespace: envoy-gateway
+    name: target-gateway-1
+  spec:
+    http1:
+      enableTrailers: true
+      preserveHeaderCase: true
+    targetRef:
+      group: gateway.networking.k8s.io
+      kind: Gateway
+      name: gateway-1
+      namespace: envoy-gateway
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http-1
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: Same
+    - name: http-2
+      protocol: HTTP
+      port: 8080
+      allowedRoutes:
+        namespaces:
+          from: Same
diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-preserve-case.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-preserve-case.out.yaml
new file mode 100644
index 000000000..c291e3692
--- /dev/null
+++ b/internal/gatewayapi/testdata/clienttrafficpolicy-preserve-case.out.yaml
@@ -0,0 +1,146 @@
+clientTrafficPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: ClientTrafficPolicy
+  metadata:
+    creationTimestamp: null
+    name: target-gateway-1
+    namespace: envoy-gateway
+  spec:
+    http1:
+      enableTrailers: true
+      preserveHeaderCase: true
+    targetRef:
+      group: gateway.networking.k8s.io
+      kind: Gateway
+      name: gateway-1
+      namespace: envoy-gateway
+  status:
+    conditions:
+    - lastTransitionTime: null
+      message: ClientTrafficPolicy has been accepted.
+      reason: Accepted
+      status: "True"
+      type: Accepted
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http-1
+      port: 80
+      protocol: HTTP
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http-2
+      port: 8080
+      protocol: HTTP
+  status:
+    listeners:
+    - attachedRoutes: 0
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http-1
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+    - attachedRoutes: 0
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http-2
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+infraIR:
+  envoy-gateway/gateway-1:
+    proxy:
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http-1
+        ports:
+        - containerPort: 10080
+          name: http-1
+          protocol: HTTP
+          servicePort: 80
+      - address: null
+        name: envoy-gateway/gateway-1/http-2
+        ports:
+        - containerPort: 8080
+          name: http-2
+          protocol: HTTP
+          servicePort: 8080
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gateway-name: gateway-1
+          gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+      name: envoy-gateway/gateway-1
+xdsIR:
+  envoy-gateway/gateway-1:
+    accessLog:
+      text:
+      - path: /dev/stdout
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      http1:
+        enableTrailers: true
+        preserveHeaderCase: true
+      isHTTP2: false
+      name: envoy-gateway/gateway-1/http-1
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      http1:
+        enableTrailers: true
+        preserveHeaderCase: true
+      isHTTP2: false
+      name: envoy-gateway/gateway-1/http-2
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 8080
diff --git a/internal/gatewayapi/testdata/envoypatchpolicy-invalid-feature-disabled.in.yaml b/internal/gatewayapi/testdata/envoypatchpolicy-invalid-feature-disabled.in.yaml
new file mode 100644
index 000000000..4942b8030
--- /dev/null
+++ b/internal/gatewayapi/testdata/envoypatchpolicy-invalid-feature-disabled.in.yaml
@@ -0,0 +1,43 @@
+envoyproxy:
+  apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: EnvoyProxy
+  metadata:
+    namespace: envoy-gateway-system
+    name: test
+  spec:
+    mergeGateways: true
+envoyPatchPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: EnvoyPatchPolicy
+  metadata:
+    namespace: envoy-gateway
+    name: edit-conn-buffer-bytes
+  spec:
+    type: "JSONPatch"
+    targetRef:
+      group: gateway.networking.k8s.io
+      kind: GatewayClass
+      name: envoy-gateway-class
+      namespace: envoy-gateway
+    jsonPatches:
+    - type: "type.googleapis.com/envoy.config.listener.v3.Listener"
+      name: "envoy-gateway-gateway-1-http"
+      operation:
+        op: replace
+        path: "/per_connection_buffer_limit_bytes"
+        value: "1024"
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: Same
diff --git a/internal/gatewayapi/testdata/envoypatchpolicy-invalid-feature-disabled.out.yaml b/internal/gatewayapi/testdata/envoypatchpolicy-invalid-feature-disabled.out.yaml
new file mode 100644
index 000000000..711b21017
--- /dev/null
+++ b/internal/gatewayapi/testdata/envoypatchpolicy-invalid-feature-disabled.out.yaml
@@ -0,0 +1,92 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http
+      port: 80
+      protocol: HTTP
+  status:
+    listeners:
+    - attachedRoutes: 0
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+infraIR:
+  envoy-gateway-class:
+    proxy:
+      config:
+        apiVersion: gateway.envoyproxy.io/v1alpha1
+        kind: EnvoyProxy
+        metadata:
+          creationTimestamp: null
+          name: test
+          namespace: envoy-gateway-system
+        spec:
+          logging: {}
+          mergeGateways: true
+        status: {}
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http
+        ports:
+        - containerPort: 10080
+          name: envoy-gateway/gateway-1/http
+          protocol: HTTP
+          servicePort: 80
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gatewayclass: envoy-gateway-class
+      name: envoy-gateway-class
+xdsIR:
+  envoy-gateway-class:
+    accessLog:
+      text:
+      - path: /dev/stdout
+    envoyPatchPolicies:
+    - name: edit-conn-buffer-bytes
+      namespace: envoy-gateway
+      status:
+        conditions:
+        - lastTransitionTime: null
+          message: EnvoyPatchPolicy is disabled in the EnvoyGateway configuration
+          reason: Disabled
+          status: "False"
+          type: Accepted
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      isHTTP2: false
+      name: envoy-gateway/gateway-1/http
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
diff --git a/internal/gatewayapi/translator_test.go b/internal/gatewayapi/translator_test.go
index 9ad3dace3..888a89d64 100644
--- a/internal/gatewayapi/translator_test.go
+++ b/internal/gatewayapi/translator_test.go
@@ -43,6 +43,16 @@ func mustUnmarshal(t *testing.T, val []byte, out interface{}) {
 }
 
 func TestTranslate(t *testing.T) {
+	testCasesConfig := []struct {
+		name                    string
+		EnvoyPatchPolicyEnabled bool
+	}{
+		{
+			name:                    "envoypatchpolicy-invalid-feature-disabled",
+			EnvoyPatchPolicyEnabled: false,
+		},
+	}
+
 	inputFiles, err := filepath.Glob(filepath.Join("testdata", "*.in.yaml"))
 	require.NoError(t, err)
 
@@ -54,11 +64,19 @@ func TestTranslate(t *testing.T) {
 
 			resources := &Resources{}
 			mustUnmarshal(t, input, resources)
+			envoyPatchPolicyEnabled := true
+
+			for _, config := range testCasesConfig {
+				if config.name == strings.Split(filepath.Base(inputFile), ".")[0] {
+					envoyPatchPolicyEnabled = config.EnvoyPatchPolicyEnabled
+				}
+			}
 
 			translator := &Translator{
-				GatewayControllerName:  egv1a1.GatewayControllerName,
-				GatewayClassName:       "envoy-gateway-class",
-				GlobalRateLimitEnabled: true,
+				GatewayControllerName:   egv1a1.GatewayControllerName,
+				GatewayClassName:        "envoy-gateway-class",
+				GlobalRateLimitEnabled:  true,
+				EnvoyPatchPolicyEnabled: envoyPatchPolicyEnabled,
 			}
 
 			// Add common test fixtures
diff --git a/internal/ir/xds_test.go b/internal/ir/xds_test.go
index 6145fd1e9..c2a55a4ea 100644
--- a/internal/ir/xds_test.go
+++ b/internal/ir/xds_test.go
@@ -42,6 +42,20 @@ var (
 			}}},
 		Routes: []*HTTPRoute{&happyHTTPRoute},
 	}
+	redactedHappyHTTPSListener = HTTPListener{
+		Name:      "happy",
+		Address:   "0.0.0.0",
+		Port:      80,
+		Hostnames: []string{"example.com"},
+		TLS: &TLSConfig{
+			Certificates: []TLSCertificate{{
+
+				Name:              "happy",
+				ServerCertificate: []byte{1, 2, 3},
+				PrivateKey:        redacted,
+			}}},
+		Routes: []*HTTPRoute{&happyHTTPRoute},
+	}
 	invalidAddrHTTPListener = HTTPListener{
 		Name:      "invalid-addr",
 		Address:   "1.0.0",
@@ -1217,7 +1231,7 @@ func TestPrintable(t *testing.T) {
 				HTTP: []*HTTPListener{&happyHTTPSListener},
 			},
 			want: &Xds{
-				HTTP: []*HTTPListener{&happyHTTPListener},
+				HTTP: []*HTTPListener{&redactedHappyHTTPSListener},
 			},
 		},
 	}
diff --git a/internal/xds/translator/testdata/in/xds-ir/http1-preserve-case.yaml b/internal/xds/translator/testdata/in/xds-ir/http1-preserve-case.yaml
new file mode 100644
index 000000000..f857ac8f8
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/http1-preserve-case.yaml
@@ -0,0 +1,40 @@
+http:
+- name: "first-listener"
+  address: "0.0.0.0"
+  port: 10080
+  hostnames:
+  - "*"
+  http1:
+    preserveHeaderCase: true
+  path:
+    mergeSlashes: true
+    escapedSlashesAction: UnescapeAndRedirect
+  routes:
+  - name: "first-route"
+    hostname: "*"
+    destination:
+      name: "first-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.4"
+          port: 50000
+- name: "second-listener"
+  address: "0.0.0.0"
+  port: 10081
+  hostnames:
+  - "*"
+  http1:
+    preserveHeaderCase: true
+    enableTrailers: true
+  path:
+    mergeSlashes: true
+    escapedSlashesAction: UnescapeAndRedirect
+  routes:
+  - name: "second-route"
+    hostname: "*"
+    destination:
+      name: "second-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.5"
+          port: 50000
diff --git a/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.clusters.yaml
new file mode 100644
index 000000000..3799e281e
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.clusters.yaml
@@ -0,0 +1,49 @@
+- commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_ONLY
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: first-route-dest
+  lbPolicy: LEAST_REQUEST
+  name: first-route-dest
+  outlierDetection: {}
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+  typedExtensionProtocolOptions:
+    envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
+      '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
+      explicitHttpConfig:
+        httpProtocolOptions:
+          headerKeyFormat:
+            statefulFormatter:
+              name: preserve_case
+              typedConfig:
+                '@type': type.googleapis.com/envoy.extensions.http.header_formatters.preserve_case.v3.PreserveCaseFormatterConfig
+- commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_ONLY
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: second-route-dest
+  lbPolicy: LEAST_REQUEST
+  name: second-route-dest
+  outlierDetection: {}
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+  typedExtensionProtocolOptions:
+    envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
+      '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
+      explicitHttpConfig:
+        httpProtocolOptions:
+          enableTrailers: true
+          headerKeyFormat:
+            statefulFormatter:
+              name: preserve_case
+              typedConfig:
+                '@type': type.googleapis.com/envoy.extensions.http.header_formatters.preserve_case.v3.PreserveCaseFormatterConfig
diff --git a/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.endpoints.yaml
new file mode 100644
index 000000000..28a57caf3
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.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/http1-preserve-case.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.listeners.yaml
new file mode 100644
index 000000000..8827d4b75
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.listeners.yaml
@@ -0,0 +1,79 @@
+- address:
+    socketAddress:
+      address: 0.0.0.0
+      portValue: 10080
+  defaultFilterChain:
+    filters:
+    - name: envoy.filters.network.http_connection_manager
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+        commonHttpProtocolOptions:
+          headersWithUnderscoresAction: REJECT_REQUEST
+        http2ProtocolOptions:
+          initialConnectionWindowSize: 1048576
+          initialStreamWindowSize: 65536
+          maxConcurrentStreams: 100
+        httpFilters:
+        - name: envoy.filters.http.router
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+        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
+        statPrefix: http
+        upgradeConfigs:
+        - upgradeType: websocket
+        useRemoteAddress: true
+  name: first-listener
+  perConnectionBufferLimitBytes: 32768
+- address:
+    socketAddress:
+      address: 0.0.0.0
+      portValue: 10081
+  defaultFilterChain:
+    filters:
+    - name: envoy.filters.network.http_connection_manager
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+        commonHttpProtocolOptions:
+          headersWithUnderscoresAction: REJECT_REQUEST
+        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
+        httpProtocolOptions:
+          enableTrailers: true
+          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: second-listener
+        statPrefix: http
+        upgradeConfigs:
+        - upgradeType: websocket
+        useRemoteAddress: true
+  name: second-listener
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.routes.yaml
new file mode 100644
index 000000000..d36b9824e
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http1-preserve-case.routes.yaml
@@ -0,0 +1,24 @@
+- ignorePortInHostMatching: true
+  name: first-listener
+  virtualHosts:
+  - domains:
+    - '*'
+    name: first-listener/*
+    routes:
+    - match:
+        prefix: /
+      name: first-route
+      route:
+        cluster: first-route-dest
+- ignorePortInHostMatching: true
+  name: second-listener
+  virtualHosts:
+  - domains:
+    - '*'
+    name: second-listener/*
+    routes:
+    - match:
+        prefix: /
+      name: second-route
+      route:
+        cluster: second-route-dest
diff --git a/internal/xds/translator/translator_test.go b/internal/xds/translator/translator_test.go
index ca8e39c7a..7d534d456 100644
--- a/internal/xds/translator/translator_test.go
+++ b/internal/xds/translator/translator_test.go
@@ -240,6 +240,9 @@ func TestTranslateXds(t *testing.T) {
 		{
 			name: "http1-trailers",
 		},
+		{
+			name: "http1-preserve-case",
+		},
 	}
 
 	for _, tc := range testCases {