feat(proxy): add PublicReqMiddleware for X-Public-Req gated safeguards - #18
Merged
Merged
Conversation
Internal extension that lets a single LiteLLM proxy serve both external
users (through a public Nginx ingress) and internal services (direct
ClusterIP) from one process. The public ingress injects
`X-Public-Req: 1`; this middleware keys off that header to apply two
safeguards on external requests while leaving internal traffic
untouched.
When `X-Public-Req: 1` is present:
- Strip every inbound `x-litellm-*` request header. These let callers
override internal proxy behavior (`x-litellm-api-key`,
`x-litellm-num-retries`, `x-litellm-spend-logs-metadata`,
`x-litellm-mock-response`, etc.) and must never be honored from
untrusted sources.
- Silently drop forbidden query parameters on `/v1/models` and
`/models` (`include_metadata`, `fallback_type`,
`include_model_access_groups`, `only_model_access_groups`). These
expose router fallback chains and access-group naming. Other query
params are preserved byte-for-byte.
- Strip every outbound `x-litellm-*` response header (model deployment
IDs, cache-hit flags, cost/budget annotations, ratelimit-remaining
fields) before they leave the proxy.
When the header is absent (internal callers) the middleware is a
no-op: scope passes through, response is unchanged, internal observability
headers and the `x-litellm-spend-logs-metadata` write path keep working.
Implementation:
- Pure ASGI middleware (not `BaseHTTPMiddleware`) — does not buffer
response bodies. Modifies headers only on the single
`http.response.start` ASGI message; `http.response.body` chunks
pass through verbatim. Streaming TTFT is unaffected.
- Installed via a wrapper entrypoint, `litellm_extras.entrypoint`,
invoked in place of the upstream `litellm` console script. The
wrapper imports `litellm.proxy.proxy_server` first (constructing
the FastAPI `app`), calls `add_middleware`, then delegates to the
existing Click `run_server` CLI. Python's module cache means
uvicorn's `from .proxy_server import app` returns the same mutated
instance.
Coverage:
- 12 unit tests in `tests/test_litellm/test_public_req_middleware.py`
drive the middleware directly via fake ASGI scope/send, covering
internal passthrough, inbound + outbound strip, streaming chunk
pass-through (regression guard against accidental BaseHTTPMiddleware
switch), query-strip variations, marker case-insensitivity, and
non-HTTP scope routing.
- E2E case 18 (`e2e/cases/18_public_req_middleware.md` + fixture)
exercises the running proxy:
A1 SSE stream produces >=3 chunks
A2 streaming_phase (wall - ttfb) > 200ms (proves no buffering)
A3 public response: 0 x-litellm-* headers
A4 internal response: >=1 x-litellm-* header (control)
A5 public /v1/models?include_metadata=true returns 200 with the
same body as bare /v1/models (silent query strip)
A6 internal /v1/models?include_metadata=true returns 200 with a
different body (metadata expansion still works for internal)
A7 internal x-litellm-spend-logs-metadata reaches spend_logs
(control — proves the header would otherwise be honored)
A8 public x-litellm-spend-logs-metadata absent from spend_logs
(inbound strip verified at the DB layer)
All 8 assertions pass; the full e2e suite remains 18/18 PASS.
…t docker-compose The previous commit installed PublicReqMiddleware by overriding the `entrypoint:` in `e2e/_config/docker-compose.yml`. That works for the e2e harness, but for production it means every Kubernetes manifest / Helm chart / docker run command has to remember to swap the entrypoint too. A missed swap silently disables every X-Public-Req safeguard — including the inbound header strip — and there is no runtime error to flag the regression. Move the wrapper invocation into `docker/prod_entrypoint.sh`, the image's baked-in ENTRYPOINT. The upstream script previously dispatched to `litellm "$@"`; it now dispatches to `python -m litellm_extras.entrypoint "$@"` under both the plain and the `USE_DDTRACE=true` paths. Every deployment of this image now loads PublicReqMiddleware automatically with no extra configuration. Drop the corresponding override in `e2e/_config/docker-compose.yml` so e2e exercises the same entrypoint path that production does. If the wrapper breaks in either, the other surface catches it. Verified: - `docker inspect litellm-e2e` shows entrypoint `docker/prod_entrypoint.sh` (no docker-compose override), confirming the image's default path is what runs. - E2E case 18: 8/8 assertions PASS — middleware still strips inbound + outbound `x-litellm-*`, strips `/v1/models` forbidden query, and the spend_logs marker check confirms the inbound header never reached the proxy core. - Full e2e suite: 18/18 PASS, no regressions. Upstream-rebase note: `docker/prod_entrypoint.sh` is a small (14-line) infrastructure file that rarely changes upstream. Future merge conflicts on this file resolve by keeping our `python -m litellm_extras.entrypoint` substitution in both branches of the USE_DDTRACE conditional.
This was referenced Jun 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add an internal ASGI middleware that lets a single LiteLLM proxy serve
both external users (through a public Nginx ingress) and internal
services (direct ClusterIP) from one process. The public ingress
injects
X-Public-Req: 1; the middleware keys off that header to applytwo safeguards on external requests while leaving internal traffic
untouched.
When
X-Public-Req: 1is present, the middleware:x-litellm-*request header. Otherwise userscould inject
x-litellm-api-key(override Authorization),x-litellm-num-retries(one request → N upstream calls),x-litellm-spend-logs-metadata(poison audit logs),x-litellm-mock-response(forge responses), etc./v1/modelsand/models(include_metadata,fallback_type,include_model_access_groups,only_model_access_groups). Theseexpose router fallback chains and access-group naming. Other query
params are preserved byte-for-byte; the request still returns 200.
x-litellm-*response header (modeldeployment IDs, cache-hit flags, response cost, ratelimit-remaining)
before they leave the proxy.
When the header is absent, the middleware is a no-op — internal
services keep using override headers and full observability fields.
How it's installed
litellm_extras/entrypoint.pyis a wrapper around the upstreamlitellmCLI that importslitellm.proxy.proxy_serverfirst(constructing the FastAPI
app), callsadd_middleware, thendelegates to the existing Click
run_serverCLI. Python's modulecache means uvicorn's
from .proxy_server import appreturns the samemutated instance.
e2e/_config/docker-compose.ymlis switched to invokepython -m litellm_extras.entrypointinentrypoint:. Productiondeploys should mirror the same swap.
Streaming safety
The middleware is pure ASGI, not
BaseHTTPMiddleware. It does NOTbuffer response bodies — header changes happen only on the single
http.response.startmessage;http.response.bodychunks passthrough verbatim. Streaming TTFT is unaffected, verified by both unit
test (4-chunk send order assertion) and e2e (
streaming_phase > 200mson a real Anthropic streaming call).
Test plan
Unit tests (
tests/test_litellm/test_public_req_middleware.py) — 12 tests:x-litellm-*headersx-litellm-*headersmore_body/v1/models?include_metadata=...&team_id=keep→ onlyteam_idreaches inner app/v1/models?include_metadata=true&only_model_access_groups=1→ empty query string/v1/models?team_id=alpha&scope=expand→ byte-identical passthrough"1"treated as internalX-Public-ReqandX-Litellm-Tagsboth handledE2E case 18 (
e2e/cases/18_public_req_middleware.md, paid) — 8 assertions:streaming_phase = wall - ttfb > 200ms(proves no buffering)x-litellm-*headersx-litellm-*header (control)/v1/models?include_metadata=truereturns 200 with same body as bare/v1/models?include_metadata=truereturns 200 with different bodyx-litellm-spend-logs-metadatareaches spend_logs (control)x-litellm-spend-logs-metadataabsent from spend_logs (DB-layer verification)Full e2e suite (
e2e/tools/run-all-cases) — 18/18 PASS, no regressionsuv run black .cleanuv run ruff checkclean