Repository: envoyproxy/gateway

Issue:
[Regression] TLS Inspector and Proxy Protocol Listener Filter Misordering
*Description*:

_Key Facts:_
- We recently attempted to upgrade to 1.5.0 from 1.3.0.
- We run in merged gateways mode
- We utilise Proxy Protocol with AWS NLB

Listener filters are the basis upon which low-level transport stream inspection and mutation occur.
As such, their ordering is highly significant and incorrect ordering can lead to unintended behaviour.

Proxy Protocol V2 (PPv2) involves a fronting intermediary (usually a load balancer) injecting a simple binary TLV sequence into the TCP bytestream to identify the originating address in an application-protocol agnostic way. The receiver is meant to consume this data and restore the bytestream before handing off to higher-layer libraries/frameworks/filters. Envoy implements PPv2 support in a listener filter.

The TLS inspector filter is also a listener filter. It is critical in scenarios where you want to do SNI matching against one or more filter chains. It inspects the early TLS ClientHello to extract the SNI extension and populates the stream context with this information.

When using these features together, it is _imperative_ that the PPv2 filter is executed before the TLS inspector filter. Unfortunately, the latest version of Envoy Gateway seems prioritise the TLS inspector over the PPv2 listener. This is a regression from previous behaviour.

*Repro steps*:

1. Setup a Envoy Gateway deployment using:
   - Merged Gateways mode
   - Multiple Gateways on a single port with TLS termination configured and appropriate HTTPRoutes
   - ClientTrafficPolicy configured to expect Proxy Protocol
2. Send PPv2-marked traffic to an Envoy
   - This can be done with [cURL locally](https://inkel.github.io/posts/proxy-protocol-support-in-curl/)
This should trigger a `filter_chain_not_found` result.

*Environment*:
[NLB with Proxy Protocol enabled] -> [Envoy Gateway with merged gateways and Proxy Protocol configured on CTP]

Working Version: 1.3.0
Regression Version: 1.5.0

*Logs*:

Example log under the failure scenario:
```
{":authority":null,"bytes_received":0,"bytes_sent":0,"connection_termination_details":null,"downstream_local_address":"10.25.27.5:443","downstream_remote_address":"12.34.56.78:18486","duration":0,"method":null,"protocol":null,"requested_server_name":null,"response_code":0,"response_code_details":"filter_chain_not_found","response_flags":"NR","route_name":null,"start_time":"2025-08-29T13:04:54.554Z","upstream_cluster":null,"upstream_host":null,"upstream_local_address":null,"upstream_transport_failure_reason":null,"user-agent":null,"x-envoy-origin-path":null,"x-envoy-upstream-service-time":null,"x-forwarded-for":null,"x-request-id":null}
```
With the TLS Inspector listener filter ordered _before_ the Proxy Protocol listener, the TLS Inspector gets garbled data when inspecting the stream, and hence fails to extract the SNI. As such, `requested_server_name` is empty.

*Config Dumps*:
[Working configuration from 1.3.0](https://github.com/user-attachments/files/22047665/listeners_1_3_0.json)

[Broken configuration from 1.5.0](https://github.com/user-attachments/files/22047666/listeners_1_5_0.json)

Hints from issue comments:
@jacobneiltaylor: Addressed in https://github.com/envoyproxy/gateway/pull/6877@arkodg: thanks for flagging this, looks like a regression from https://github.com/envoyproxy/gateway/pull/6545
can we follow the same style of code that existed before ^ and also add a test case with proxy protocol and tls inspector in 
https://github.com/envoyproxy/gateway/tree/main/internal/xds/translator/testdata
to ensure we avoid another regression

Solution patch:
diff --git a/internal/xds/translator/proxy_protocol.go b/internal/xds/translator/proxy_protocol.go
index 453cb0ed2..ada9bd301 100644
--- a/internal/xds/translator/proxy_protocol.go
+++ b/internal/xds/translator/proxy_protocol.go
@@ -6,6 +6,8 @@
 package translator
 
 import (
+	"slices"
+
 	listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
 	proxyprotocolv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/listener/proxy_protocol/v3"
 	"github.com/envoyproxy/go-control-plane/pkg/wellknown"
@@ -37,7 +39,7 @@ func patchProxyProtocolFilter(xdsListener *listenerv3.Listener, proxyProtocolSet
 	// Build and patch the Proxy Protocol Filter.
 	filter := buildProxyProtocolFilter(proxyProtocolSettings)
 	if filter != nil {
-		xdsListener.ListenerFilters = append(xdsListener.ListenerFilters, filter)
+		xdsListener.ListenerFilters = slices.Insert(xdsListener.ListenerFilters, 0, filter)
 	}
 }
 



Test patch:
diff --git a/internal/xds/translator/testdata/in/xds-ir/listener-proxy-protocol-multi.yaml b/internal/xds/translator/testdata/in/xds-ir/listener-proxy-protocol-multi.yaml
new file mode 100644
index 000000000..bd6e7acb7
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/listener-proxy-protocol-multi.yaml
@@ -0,0 +1,44 @@
+http:
+- name: "listener"
+  address: "::"
+  port: 10080
+  hostnames:
+  - "foo.com"
+  - "bar.com"
+  path:
+    mergeSlashes: true
+    escapedSlashesAction: UnescapeAndRedirect
+  tls:
+    alpnProtocols:
+    - h2
+    - http/1.1
+    certificates:
+    - name: secret-1
+      # byte slice representation of "key-data"
+      certificate: [99, 101, 114, 116, 45, 100, 97, 116, 97]
+      # byte slice representation of "key-data"
+      privateKey: [107, 101, 121, 45, 100, 97, 116, 97]
+    - name: secret-2
+      certificate: [99, 101, 114, 116, 45, 100, 97, 116, 97]
+      privateKey: [107, 101, 121, 45, 100, 97, 116, 97]
+  proxyProtocol:
+    optional: false
+  routes:
+  - name: "first-route"
+    hostname: "foo.com"
+    destination:
+      name: "first-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.4"
+          port: 50000
+        name: "first-route-dest/backend/0"
+  - name: "second-route"
+    hostname: "bar.com"
+    destination:
+      name: "second-route-dest"
+      settings:
+      - endpoints:
+        - host: "5.6.7.8"
+          port: 50000
+        name: "second-route-dest/backend/0"
diff --git a/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.clusters.yaml
new file mode 100644
index 000000000..7e9eb8071
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.clusters.yaml
@@ -0,0 +1,48 @@
+- 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
+- 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/listener-proxy-protocol-multi.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.endpoints.yaml
new file mode 100644
index 000000000..39102da40
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.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: 5.6.7.8
+            portValue: 50000
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality:
+      region: second-route-dest/backend/0
diff --git a/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.listeners.yaml
new file mode 100644
index 000000000..4a01e1c2f
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.listeners.yaml
@@ -0,0 +1,65 @@
+- address:
+    socketAddress:
+      address: '::'
+      portValue: 10080
+  filterChains:
+  - filterChainMatch:
+      serverNames:
+      - foo.com
+      - bar.com
+    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
+        mergeSlashes: true
+        normalizePath: true
+        pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT
+        rds:
+          configSource:
+            ads: {}
+            resourceApiVersion: V3
+          routeConfigName: listener
+        serverHeaderTransformation: PASS_THROUGH
+        statPrefix: https-10080
+        useRemoteAddress: true
+    name: listener
+    transportSocket:
+      name: envoy.transport_sockets.tls
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
+        commonTlsContext:
+          alpnProtocols:
+          - h2
+          - http/1.1
+          tlsCertificateSdsSecretConfigs:
+          - name: secret-1
+            sdsConfig:
+              ads: {}
+              resourceApiVersion: V3
+          - name: secret-2
+            sdsConfig:
+              ads: {}
+              resourceApiVersion: V3
+        disableStatefulSessionResumption: true
+        disableStatelessSessionResumption: true
+  listenerFilters:
+  - name: envoy.filters.listener.proxy_protocol
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.filters.listener.proxy_protocol.v3.ProxyProtocol
+  - name: envoy.filters.listener.tls_inspector
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
+  maxConnectionsToAcceptPerSocketEvent: 1
+  name: listener
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.routes.yaml
new file mode 100644
index 000000000..21193de51
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.routes.yaml
@@ -0,0 +1,25 @@
+- ignorePortInHostMatching: true
+  name: listener
+  virtualHosts:
+  - domains:
+    - foo.com
+    name: listener/foo_com
+    routes:
+    - match:
+        prefix: /
+      name: first-route
+      route:
+        cluster: first-route-dest
+        upgradeConfigs:
+        - upgradeType: websocket
+  - domains:
+    - bar.com
+    name: listener/bar_com
+    routes:
+    - match:
+        prefix: /
+      name: second-route
+      route:
+        cluster: second-route-dest
+        upgradeConfigs:
+        - upgradeType: websocket
diff --git a/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.secrets.yaml b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.secrets.yaml
new file mode 100644
index 000000000..ad88ffe43
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/listener-proxy-protocol-multi.secrets.yaml
@@ -0,0 +1,12 @@
+- name: secret-1
+  tlsCertificate:
+    certificateChain:
+      inlineBytes: Y2VydC1kYXRh
+    privateKey:
+      inlineBytes: a2V5LWRhdGE=
+- name: secret-2
+  tlsCertificate:
+    certificateChain:
+      inlineBytes: Y2VydC1kYXRh
+    privateKey:
+      inlineBytes: a2V5LWRhdGE=