Skip to content

feat(proxy): add PublicReqMiddleware for X-Public-Req gated safeguards - #18

Merged
songkuan-zheng merged 2 commits into
ship/v1.83.10from
fix/public-req-middleware
May 22, 2026
Merged

feat(proxy): add PublicReqMiddleware for X-Public-Req gated safeguards#18
songkuan-zheng merged 2 commits into
ship/v1.83.10from
fix/public-req-middleware

Conversation

@songkuan-zheng

Copy link
Copy Markdown
Collaborator

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 apply
two safeguards on external requests while leaving internal traffic
untouched.

When X-Public-Req: 1 is present, the middleware:

  • Strips every inbound x-litellm-* request header. Otherwise users
    could 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.
  • Silently strips forbidden query parameters from /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; the request still returns 200.
  • Strips every outbound x-litellm-* response header (model
    deployment 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.py is a wrapper around the upstream
litellm CLI that 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.

e2e/_config/docker-compose.yml is switched to invoke
python -m litellm_extras.entrypoint in entrypoint:. Production
deploys should mirror the same swap.

Streaming safety

The middleware is pure ASGI, not BaseHTTPMiddleware. It does NOT
buffer response bodies — header changes happen only on the single
http.response.start message; http.response.body chunks pass
through verbatim. Streaming TTFT is unaffected, verified by both unit
test (4-chunk send order assertion) and e2e (streaming_phase > 200ms
on a real Anthropic streaming call).

Test plan

  • Unit tests (tests/test_litellm/test_public_req_middleware.py) — 12 tests:

    • internal passthrough leaves scope/response untouched
    • public mode strips inbound x-litellm-* headers
    • public mode strips outbound x-litellm-* headers
    • streaming body chunks pass through one-by-one with correct more_body
    • /v1/models?include_metadata=...&team_id=keep → only team_id reaches 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
    • Marker value other than "1" treated as internal
    • Mixed-case X-Public-Req and X-Litellm-Tags both handled
    • Non-HTTP (websocket / lifespan) scope passes through
  • E2E case 18 (e2e/cases/18_public_req_middleware.md, paid) — 8 assertions:

    • A1 SSE stream ≥ 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 same body as bare
    • A6 internal /v1/models?include_metadata=true returns 200 with different body
    • A7 internal x-litellm-spend-logs-metadata reaches spend_logs (control)
    • A8 public x-litellm-spend-logs-metadata absent from spend_logs (DB-layer verification)
  • Full e2e suite (e2e/tools/run-all-cases) — 18/18 PASS, no regressions

  • uv run black . clean

  • uv run ruff check clean

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.
@songkuan-zheng
songkuan-zheng merged commit 1ae68ea into ship/v1.83.10 May 22, 2026
1 check passed
@songkuan-zheng
songkuan-zheng deleted the fix/public-req-middleware branch May 22, 2026 09:33
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.

1 participant