Skip to content

feat(routing): emit the picked cluster as a configurable route header - #1178

Closed
hexfusion wants to merge 2 commits into
praxis-proxy:mainfrom
hexfusion:feat/routing-route-header
Closed

hexfusion wants to merge 2 commits into
praxis-proxy:mainfrom
hexfusion:feat/routing-route-header

Conversation

@hexfusion

Copy link
Copy Markdown

Summary

Add an optional route_header to the intelligent_route filter. When set, the filter emits the chosen cluster as that request header on every pick; when unset, behavior is unchanged and no header is emitted.

Motivation

The filter selects a cluster and sets it on the request context, which a downstream load_balancer filter reads. A gateway that routes by header instead (ext_proc behind Envoy) has no way to learn the pick, so the routing decision is not carried to it. This adds the decision-carrying header. It is independent of load scoring: it emits whichever candidate any policy picked.

What changed

A route_header config field on the intelligent_route filter, validated at parse with the same rule as model_header. On both the fresh-selection and the session-affinity-reused paths, once a candidate is chosen the filter sets route_header to that candidate's cluster. A reused pick emits it too, so a header-routing gateway honours the bound cluster rather than falling back. A cluster name that is not a valid header value is logged and skipped, not fatal.

The route header is not in the reserved header namespace, so the protocol layer's reserved-header guard does not reject a client that forges it. The filter strips a configured route_header from the request at entry, alongside the internal routing headers, so only a pick emits it. On the pick path the set wins over the strip; on a non-pick path (no model header, or a cluster already set upstream) the request fails closed with no client-supplied destination reaching the gateway.

The filter doc is regenerated for the new field.

Header injection

The route header sits outside the reserved x-praxis- / x-mcp- namespace, so the protocol layer's reserved-header rejection does not cover it, and the strip is the control. A dedicated security review confirmed injection is prevented on both the header-routing and the metadata-mirror topologies:

  • The filter strips a configured route_header at on_request entry, before every exit: the no-model-header skip, the preset-cluster pass-through, and the reject paths. A non-pick request fails closed with no client value surviving.
  • On a pick the filter's set overrides the strip, so the picked cluster wins.
  • Removal is case-insensitive, so a differently cased client header is still stripped.
  • A duplicate client header cannot leak: removal drops every instance, and trusted header resolution excludes the raw request headers.
  • A cluster name that is not a valid header value is rejected by HeaderValue::from_str (control bytes, CR, LF), so a crafted cluster cannot smuggle a second header.
  • In the metadata-mirror topology the ext_proc adapter mirrors the header the filter set, never the raw client request.

Worked example (Envoy)

The filter change here is only the route_header emission. The consuming side is an ext_proc adapter in picker_mode that mirrors the emitted header into envoy.lb dynamic metadata, and an Envoy config that routes on that metadata. Header alone does not steer Envoy (the route and host are chosen before a body-phase header mutation lands), so the value has to reach the load balancer as dynamic metadata.

The ext_proc server running the filter:

server:
  picker_mode: true                                 # mirror the route header into envoy.lb metadata
filter_chains:
  - name: main
    filters:
      - filter: intelligent_route
        model_header: x-gateway-model-name          # client sends the model here
        route_header: x-gateway-destination-endpoint # the filter sets the chosen cluster here
        local_site: hub
        candidates:
          - { kind: inference_model, name: model-a, site: hub, cluster: "echo-a.svc.cluster.local:8080" }
          - { kind: inference_model, name: model-b, site: hub, cluster: "echo-b.svc.cluster.local:8080" }

Envoy accepts the metadata and forwards to the chosen host:

http_filters:
- name: envoy.filters.http.ext_proc
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor
    grpc_service: { envoy_grpc: { cluster_name: ext_proc_cluster } }
    metadata_options:
      receiving_namespaces:
        untyped: [envoy.lb]                          # accept the metadata the picker sets
    processing_mode: { request_header_mode: SEND, response_header_mode: SKIP }
- name: envoy.filters.http.header_mutation
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.header_mutation.v3.HeaderMutation
    mutations:
      request_mutations:
      - append:
          header: { key: x-chosen-host, value: '%DYNAMIC_METADATA(["envoy.lb","x-gateway-destination-endpoint"])%' }
          append_action: OVERWRITE_IF_EXISTS_OR_ADD
- name: envoy.filters.http.dynamic_forward_proxy
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig
    dns_cache_config: { name: dfp_cache, dns_lookup_family: V4_ONLY }
- name: envoy.filters.http.router

The route sends to the dynamic-forward-proxy cluster and rewrites the host from that header:

route:
  cluster: dfp_cluster
typed_per_filter_config:
  envoy.filters.http.dynamic_forward_proxy:
    "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.PerRouteConfig
    host_rewrite_header: x-chosen-host

Flow: client sends x-gateway-model-name, intelligent_route picks a candidate and sets x-gateway-destination-endpoint to its cluster FQDN, picker_mode mirrors that into envoy.lb metadata, header_mutation copies it to x-chosen-host, and dynamic_forward_proxy routes to that FQDN. The same metadata can instead drive an ORIGINAL_DST cluster subset. A request that reaches Envoy without a pick carries no picker-set metadata, and the filter strips any client-supplied route_header, so a forged value cannot route.

@hexfusion
hexfusion requested review from a team and franciscojavierarceo September 15, 2026 10:18

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: feat(routing): emit the picked cluster as a configurable route header

Summary: Clean feature addition. The strip-then-set ordering is correct (removes apply before sets in the pipeline), the anti-spoofing covers all exit paths (skip, cluster-already-set, reject, pick), and HeaderValue::from_str blocks control-character injection. Test coverage is thorough: pick, no-config, spoofed-overwrite, no-pick strip, unencodable cluster, session reuse, and config validation. Two medium findings.

Severity Count
Medium 2

Comment thread filters/src/routing/intelligent_route.rs
Comment thread filters/src/routing/intelligent_route.rs Outdated
The filter sets ctx.cluster for a downstream load_balancer filter to read. A
gateway that routes by header instead (ext_proc behind Envoy) has no way to
learn the pick. Add an optional route_header: when set, the filter emits the
chosen cluster as that request header on every pick, including a
session-affinity-reused pick, so a header-routing gateway honours the bound
cluster. Unset, behavior is unchanged. The header name is validated at parse
with the same rule as model_header.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
… unencodable

From praxis-bot review:

- When an earlier filter pre-sets ctx.cluster and route_header is configured, the
  preserve path now emits the route header too, so a header-routing gateway is not
  left without a destination. A forged client value is still stripped first.
- emit_route_header logs an unencodable cluster name at warn rather than debug, so
  a misconfigured cluster is visible without debug tracing.

emit_route_header now takes the cluster name, shared by the pick and preserve
paths. Adds a regression test for the pre-set-cluster case.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
@hexfusion
hexfusion force-pushed the feat/routing-route-header branch from 0903788 to ea8f3d2 Compare September 15, 2026 20:10

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: route_header can overwrite authenticated provider-hop context.
Configuration accepts x-ai-routing-candidate, x-ai-routing-request-id, and x-ai-routing-revision. For a provider-hop candidate, the filter first queues the correct authenticated context, then queues the cluster under the same configured name. Praxis applies queued sets in order, so the cluster wins and the provider receives corrupted routing context. Reject all three AI-owned names. Configuration, provider-context writes, later route-header write.

P2: Praxis-reserved extension headers are accepted and subsequently stripped.
The local validator only reserves x-praxis-* and x-mcp-, while Praxis 0.5.4 also reserves x-ext-protocol- and x-ext-agent-*. Configuring one of those as the destination header passes filter construction but Praxis removes it before upstream dispatch, leaving the header-routing gateway without a destination. AI validator, Praxis contract.

P2: An unencodable cluster still reports successful routing without a destination header.
Praxis Bot noticed this path but requested only a warning-level log. The current fix logs warn! and continues. In the documented header-routing topology, downstream routing reads the header rather than ctx.cluster; the request therefore proceeds without its required destination and may hit a default route or fail later. Validate cluster encodability when route_header is configured, or fail the request. Current behavior.

@hexfusion

Copy link
Copy Markdown
Author

since we are not going to be using v2 for grid I am going to close this

@leseb

leseb commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

#1178 (comment)

@leseb leseb closed this Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants