What happened:
When the EPP routes a request decode-only, a client-supplied x-prefiller-host-port header reaches the P/D sidecar unchanged. The sidecar sends the request body to that host and port and returns that host's response body to the client. That lets a client send requests to any address reachable from the decode pod's network, such as other pods, cluster services, or node-local endpoints.
The EPP routes decode-only for short prompts, cached prompts, and any request the PD decider declines. The client can trigger this path by sending a short prompt. When the EPP selects a prefill endpoint, its value overwrites the client's, so prefill-decode requests are not affected.
The sidecar allowlist (--enable-ssrf-protection) blocks this, but it defaults to false, and no config under config/ or deploy/ enables it.
What you expected to happen:
The EPP owns the routing headers. A client-supplied value is removed from the request before Envoy forwards it, whether or not the scheduler sets the header.
How to reproduce it (as minimally and precisely as possible):
Setup: a P/D deployment with the disagg-profile-handler and prefix-based-pd-decider (nonCachedTokens: 16), and a decode pod running the sidecar with --kv-connector=nixlv2 and the default --enable-ssrf-protection=false. The target below is the EPP pod's metrics port, standing in for an internal service.
curl -s localhost:18081/v1/completions \
-H 'content-type: application/json' \
-H 'x-prefiller-host-port: 10.244.0.113:9090' \
-d '{"model":"Qwen/Qwen3-8B","prompt":"Hi there","max_tokens":4}'
# HTTP 404, body "404 page not found", returned by the EPP metrics server
Decode sidecar log for that request:
"SSRF protection: prefill target allowed" target=10.244.0.113:9090
"running NIXL protocol V2" url=10.244.0.113:9090
"sending prefill request" to=10.244.0.113:9090
"prefill request failed"
Other routing headers the sidecar reads, from the same test:
| Header |
Reaches sidecar |
Effect in this setup |
x-prefiller-host-port |
Yes |
Request sent to the client-chosen host; response returned to the client |
x-encoder-hosts-ports |
Yes |
Accepted as encoder targets; not used only because the request had no multimodal content |
x-data-parallel-host-port |
Yes |
Request fails with 400 |
x-kv-cache-source-host-port |
Yes |
Ignored by the nixlv2 connector |
Anything else we need to know?:
Root cause: disagg.Handler.PreRequest removes the header from the EPP's copy of the request:
// pkg/epp/framework/plugins/scheduling/profilehandler/disagg/disagg_profile_handler.go:520
delete(request.Headers, routing.PrefillEndpointHeader)
The ext_proc request-headers response sets headers only:
// pkg/epp/handlers/request.go:98-100
HeaderMutation: &extProcPb.HeaderMutation{
SetHeaders: s.generateHeaders(ctx, reqCtx),
},
A deleted key is never set again, and nothing tells Envoy to remove it, so Envoy forwards the client's value. x-encoder-hosts-ports, deleted at line 545, has the same problem. Nothing in pkg/epp sets HeaderMutation.RemoveHeaders.
Proposed fix: in the request-headers response, add the EPP-owned routing headers (x-prefiller-host-port, x-encoder-hosts-ports, x-data-parallel-host-port, x-kv-cache-source-host-port) to HeaderMutation.RemoveHeaders whenever the scheduler did not set them. A broader option is to remove every incoming header key that PreRequest deleted from reqCtx.Request.Headers. The first test would be a handler test that sends the header, runs a decode-only schedule, and asserts the key appears in RemoveHeaders.
Mitigation until fixed: run the sidecar with --enable-ssrf-protection=true and --inference-pool set. #979 notes that the allowlist matches pod IP only, not host:port.
What happened:
When the EPP routes a request decode-only, a client-supplied
x-prefiller-host-portheader reaches the P/D sidecar unchanged. The sidecar sends the request body to that host and port and returns that host's response body to the client. That lets a client send requests to any address reachable from the decode pod's network, such as other pods, cluster services, or node-local endpoints.The EPP routes decode-only for short prompts, cached prompts, and any request the PD decider declines. The client can trigger this path by sending a short prompt. When the EPP selects a prefill endpoint, its value overwrites the client's, so prefill-decode requests are not affected.
The sidecar allowlist (
--enable-ssrf-protection) blocks this, but it defaults tofalse, and no config underconfig/ordeploy/enables it.What you expected to happen:
The EPP owns the routing headers. A client-supplied value is removed from the request before Envoy forwards it, whether or not the scheduler sets the header.
How to reproduce it (as minimally and precisely as possible):
Setup: a P/D deployment with the
disagg-profile-handlerandprefix-based-pd-decider(nonCachedTokens: 16), and a decode pod running the sidecar with--kv-connector=nixlv2and the default--enable-ssrf-protection=false. The target below is the EPP pod's metrics port, standing in for an internal service.Decode sidecar log for that request:
Other routing headers the sidecar reads, from the same test:
x-prefiller-host-portx-encoder-hosts-portsx-data-parallel-host-portx-kv-cache-source-host-portAnything else we need to know?:
Root cause:
disagg.Handler.PreRequestremoves the header from the EPP's copy of the request:The ext_proc request-headers response sets headers only:
A deleted key is never set again, and nothing tells Envoy to remove it, so Envoy forwards the client's value.
x-encoder-hosts-ports, deleted at line 545, has the same problem. Nothing inpkg/eppsetsHeaderMutation.RemoveHeaders.Proposed fix: in the request-headers response, add the EPP-owned routing headers (
x-prefiller-host-port,x-encoder-hosts-ports,x-data-parallel-host-port,x-kv-cache-source-host-port) toHeaderMutation.RemoveHeaderswhenever the scheduler did not set them. A broader option is to remove every incoming header key thatPreRequestdeleted fromreqCtx.Request.Headers. The first test would be a handler test that sends the header, runs a decode-only schedule, and asserts the key appears inRemoveHeaders.Mitigation until fixed: run the sidecar with
--enable-ssrf-protection=trueand--inference-poolset. #979 notes that the allowlist matches pod IP only, not host:port.