Repository: envoyproxy/gateway

Issue:
Incorrect Cluster Mapping in Route Config When adding URLRewrite Filter
**Description:**
When generating an HTTPRoute with filters defined at both HTTPRoute/Rule and HTTPRoute/Rule/BackendRef levels, Envoy creates clusters at the HTTPRoute/Rule/BackendRef level but incorrectly maps the route to a non-existent HTTPRoute/Rule level cluster.

*Example HTTPRoute Manifest*
```
spec:
  rules:
  - backendRefs:
    - filters:
      - requestHeaderModifier:
          remove:
          - X-Unused
        type: RequestHeaderModifier
      group: ""
      kind: Service
      name: eg-validator-default
      port: 8080
      weight: 90
    - filters:
      - requestHeaderModifier:
          remove:
          - X-Unused
        type: RequestHeaderModifier
      group: ""
      kind: Service
      name: eg-validator-canary
      port: 8080
      weight: 10
    filters:
    - type: URLRewrite
      urlRewrite:
        path:
          replacePrefixMatch: /
          type: ReplacePrefixMatch
    matches:
    - path:
        type: PathPrefix
        value: /v1
```

*Observed Envoy Cluster Config*

*Clusters created:*  
`httproute/devops/eg-validator-internal/rule/0/backend/0`
`httproute/devops/eg-validator-internal/rule/0/backend/1`

*Route mapped to:*  
`httproute/devops/eg-validator-internal/rule/0 (which is not exists)`

*Note:*  
- We encountered this issue when applying only a URLRewrite filter in HTTPRoute/Rule.
- We wants to add the filter at HTTPRoute/Rule/BackendRef, to generate the clusters at backend level. Ref: https://github.com/envoyproxy/gateway/issues/7017


**Repro steps:**

- Create an HTTPRoute as shown in the example spec with filters at both BackendRef and Rule levels.
- Apply the configuration to Envoy Gateway.
- Inspect the generated Envoy cluster and route configuration.
- Notice that the route points to a cluster name which is not exists.


**Environment:**
Kubernetes Version: 1.31
Envoy Gateway Version: 1.5.0
Gateway API Version: 1.3.0

Discussion:
@sekar-saravanan: We cannot apply URLWrite at HTTPRoute/Rule/BackendRef instead of HTTPRoute/Rule, because of this issue https://github.com/envoyproxy/gateway/issues/7020@arkodg: this config should be rejected in the gateway-api layer and should surface as a status, is that not happening ?@sekar-saravanan: No, the status shows as Accepted when the above-mentioned manifest is applied. However, upon checking the configuration, it pointed to a non-existent cluster, which resulted in a 5XX error.

```
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  labels:
    app: eg-validator
    auth: "false"
    traffic: internal
  name: eg-validator-internal
  namespace: devops
spec:
  hostnames:
  - eg-validator.internal.preprod.test.com
  - eg-validator.internal.test.com
  parentRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: envoy-gateway-proxy
    namespace: infrastructure-internal
  rules:
  - backendRefs:
    - filters:
      - requestHeaderModifier:
          remove:
          - X-Unused
        type: RequestHeaderModifier
      group: ""
      kind: Service
      name: eg-validator-default
      port: 8080
      weight: 90
    - filters:
      - requestHeaderModifier:
          remove:
          - X-Unused
        type: RequestHeaderModifier
      group: ""
      kind: Service
      name: eg-validator-canary
      port: 8080
      weight: 10
    filters:
    - type: URLRewrite
      urlRewrite:
        path:
          replacePrefixMatch: /
          type: ReplacePrefixMatch
    matches:
    - path:
        type: PathPrefix
        value: /v1
status:
  parents:
  - conditions:
    - lastTransitionTime: "2025-09-22T18:33:24Z"
      message: Route is accepted
      observedGeneration: 112
      reason: Accepted
      status: "True"
      type: Accepted
    - lastTransitionTime: "2025-09-22T18:33:24Z"
      message: Resolved all the Object references for the Route
      observedGeneration: 112
      reason: ResolvedRefs
      status: "True"
      type: ResolvedRefs
    controllerName: gateway.envoyproxy.io/gatewayclass-controller-internal
    parentRef:
      group: gateway.networking.k8s.io
      kind: Gateway
      name: envoy-gateway-proxy
      namespace: infrastructure-internal
```

@sekar-saravanan: Do you think, this is not applicable, **Adding a URLRewrite filter at the Rule level and another filter (HeaderModifier) at the Backend level?**

I tested generating a route with the correct clusters using this fix (https://github.com/envoyproxy/gateway/pull/7027/files), and the behavior worked as expected.@arkodg: yes, its not possible today and is an envoy proxy limitation
the only literal rewrite of host is possible
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-weightedcluster-clusterweight

so we need to convert this into a negative condition@sekar-saravanan: No, I was referring to [URLRewrite filters at the HTTPRoute/Rule level](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-routeaction) and [HeaderModifier filters at the HTTPRoute/Rule/Backend level](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-weightedcluster-clusterweight). I believe this should be possible.
@arkodg: my bad thanks for the clarification@sekar-saravanan: No problem. I hope, this PR https://github.com/envoyproxy/gateway/pull/7027 should resolve the issue. 

Solution patch:
diff --git a/internal/xds/translator/route.go b/internal/xds/translator/route.go
index 5cda9f063..9007fa7d5 100644
--- a/internal/xds/translator/route.go
+++ b/internal/xds/translator/route.go
@@ -71,7 +71,7 @@ func buildXdsRoute(httpRoute *ir.HTTPRoute, httpListener *ir.HTTPListener) (*rou
 	case httpRoute.Redirect != nil:
 		router.Action = &routev3.Route_Redirect{Redirect: buildXdsRedirectAction(httpRoute)}
 	case httpRoute.URLRewrite != nil:
-		routeAction := buildXdsURLRewriteAction(httpRoute.Destination.Name, httpRoute.URLRewrite, httpRoute.PathMatch)
+		routeAction := buildXdsURLRewriteAction(httpRoute, httpRoute.URLRewrite, httpRoute.PathMatch)
 		routeAction.IdleTimeout = idleTimeout(httpRoute)
 		if httpRoute.Mirrors != nil {
 			routeAction.RequestMirrorPolicies = buildXdsRequestMirrorPolicies(httpRoute.Mirrors)
@@ -446,11 +446,18 @@ func prefix2RegexRewrite(prefix string) *matcherv3.RegexMatchAndSubstitute {
 	}
 }
 
-func buildXdsURLRewriteAction(destName string, urlRewrite *ir.URLRewrite, pathMatch *ir.StringMatch) *routev3.RouteAction {
-	routeAction := &routev3.RouteAction{
-		ClusterSpecifier: &routev3.RouteAction_Cluster{
-			Cluster: destName,
-		},
+func buildXdsURLRewriteAction(route *ir.HTTPRoute, urlRewrite *ir.URLRewrite, pathMatch *ir.StringMatch) *routev3.RouteAction {
+	backendWeights := route.Destination.ToBackendWeights()
+	// only use weighted cluster when there are invalid weights
+	var routeAction *routev3.RouteAction
+	if route.NeedsClusterPerSetting() || backendWeights.Invalid != 0 {
+		routeAction = buildXdsWeightedRouteAction(backendWeights, route.Destination.Settings)
+	} else {
+		routeAction = &routev3.RouteAction{
+			ClusterSpecifier: &routev3.RouteAction_Cluster{
+				Cluster: backendWeights.Name,
+			},
+		}
 	}
 
 	if urlRewrite.Path != nil {



Test patch:
diff --git a/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.yaml b/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.yaml
new file mode 100644
index 000000000..5a7c27cf2
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.yaml
@@ -0,0 +1,68 @@
+http:
+  - name: "first-listener"
+    address: "::"
+    hostnames:
+      - '*'
+    path:
+      escapedSlashesAction: UnescapeAndRedirect
+      mergeSlashes: true
+    port: 10080
+    routes:
+      - destination:
+          name: "first-route-dest"
+          settings:
+            - addressType: IP
+              endpoints:
+                - host: 1.1.1.1
+                  port: 8080
+              name: "first-route-dest/backend/0"
+              filters:
+                addRequestHeaders:
+                  - append: false
+                    name: add-header-3
+                    value:
+                      - some-value
+              protocol: HTTP
+              weight: 1
+        hostname: '*'
+        name: "first-route"
+        pathMatch:
+          prefix: /foo
+        urlRewrite:
+          path:
+            prefixMatchReplace: /
+      - destination:
+          name: "second-route-dest"
+          settings:
+            - addressType: IP
+              endpoints:
+                - host: 2.2.2.2
+                  port: 8080
+              name: "second-route-dest/backend/0"
+              filters:
+                addRequestHeaders:
+                  - append: true
+                    name: add-header-1
+                    value:
+                      - some-value
+                  - append: true
+                    name: add-header-2
+                    value:
+                      - some-value
+              protocol: HTTP
+              weight: 8
+            - addressType: IP
+              endpoints:
+                - host: 3.3.3.3
+                  port: 8080
+              filters: {}
+              protocol: HTTP
+              weight: 2
+              name: "second-route-dest/backend/1"
+        hostname: '*'
+        name: "second-route"
+        pathMatch:
+          prefix: /bar
+        urlRewrite:
+          path:
+            prefixMatchReplace: /
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.clusters.yaml
new file mode 100644
index 000000000..f4fa08ccb
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.clusters.yaml
@@ -0,0 +1,72 @@
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: first-route-dest/backend/0
+  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/backend/0
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: second-route-dest/backend/0
+  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/backend/0
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: second-route-dest/backend/1
+  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/backend/1
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.endpoints.yaml
new file mode 100644
index 000000000..e0b91a520
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.endpoints.yaml
@@ -0,0 +1,36 @@
+- clusterName: first-route-dest/backend/0
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 1.1.1.1
+            portValue: 8080
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality:
+      region: first-route-dest/backend/0
+- clusterName: second-route-dest/backend/0
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 2.2.2.2
+            portValue: 8080
+      loadBalancingWeight: 1
+    loadBalancingWeight: 8
+    locality:
+      region: second-route-dest/backend/0
+- clusterName: second-route-dest/backend/1
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 3.3.3.3
+            portValue: 8080
+      loadBalancingWeight: 1
+    loadBalancingWeight: 2
+    locality:
+      region: second-route-dest/backend/1
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.listeners.yaml
new file mode 100644
index 000000000..5dd5e46e3
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.listeners.yaml
@@ -0,0 +1,35 @@
+- 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
+        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
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.routes.yaml
new file mode 100644
index 000000000..22e035362
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-prefix-with-weighted-backend.routes.yaml
@@ -0,0 +1,51 @@
+- ignorePortInHostMatching: true
+  name: first-listener
+  virtualHosts:
+  - domains:
+    - '*'
+    name: first-listener/*
+    routes:
+    - match:
+        pathSeparatedPrefix: /foo
+      name: first-route
+      route:
+        clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR
+        regexRewrite:
+          pattern:
+            regex: ^/foo\/*
+          substitution: /
+        upgradeConfigs:
+        - upgradeType: websocket
+        weightedClusters:
+          clusters:
+          - name: first-route-dest/backend/0
+            requestHeadersToAdd:
+            - appendAction: OVERWRITE_IF_EXISTS_OR_ADD
+              header:
+                key: add-header-3
+                value: some-value
+            weight: 1
+    - match:
+        pathSeparatedPrefix: /bar
+      name: second-route
+      route:
+        clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR
+        regexRewrite:
+          pattern:
+            regex: ^/bar\/*
+          substitution: /
+        upgradeConfigs:
+        - upgradeType: websocket
+        weightedClusters:
+          clusters:
+          - name: second-route-dest/backend/0
+            requestHeadersToAdd:
+            - header:
+                key: add-header-1
+                value: some-value
+            - header:
+                key: add-header-2
+                value: some-value
+            weight: 8
+          - name: second-route-dest/backend/1
+            weight: 2