From 8ddd02378631ef6f62aa3206530f30c0d02dda71 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Tue, 27 Oct 2026 04:00:57 +0000 Subject: [PATCH] gateway: stop forwarding provider response headers to callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteLLM copies every upstream response header onto its own response prefixed 'llm_provider-'. For our providers that hands callers operational detail about the co-op's own account — verified live: llm_provider-x-credits-remaining carried the remaining provider balance in every member's response. Build-time patch makes _get_llm_provider_headers() return {}. The four OpenAI-style x-ratelimit-* headers a provider may send are still forwarded: those describe the caller's own request, not the co-op's account. --- Dockerfile | 8 ++++ patch_strip_provider_headers.py | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 patch_strip_provider_headers.py diff --git a/Dockerfile b/Dockerfile index 5d94236..78372c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,14 @@ RUN python3 /app/code/patch_audio_request_cost.py COPY patch_audio_router_pricing.py /app/code/patch_audio_router_pricing.py RUN python3 /app/code/patch_audio_router_pricing.py +# SECURITY (2026-10-27, member-disclosure finding 4): LiteLLM forwards every +# provider response header to callers as `llm_provider-*`, which leaked the +# co-op's remaining provider balance (x-credits-remaining) to any member API key. +# Verify the "[coop-patch] applied: provider response headers" line in the build +# output on every version bump — the patch is non-fatal by design. +COPY patch_strip_provider_headers.py /app/code/patch_strip_provider_headers.py +RUN python3 /app/code/patch_strip_provider_headers.py + # Audio keep-alive: scheduled pings keep the Tinfoil TEE audio enclave # warm so voice mode skips the ~24s cold-start (see audio_keepalive.py header). COPY audio_keepalive.py /app/code/audio_keepalive.py diff --git a/patch_strip_provider_headers.py b/patch_strip_provider_headers.py new file mode 100644 index 0000000..7a4c62e --- /dev/null +++ b/patch_strip_provider_headers.py @@ -0,0 +1,75 @@ +"""Stop LiteLLM from forwarding the provider's response headers to callers. + +SECURITY (2026-10-27, member-disclosure finding 4): LiteLLM's proxy copies every +upstream response header onto its own response as `llm_provider-` — see +litellm_core_utils/llm_response_utils/get_headers.py::_get_llm_provider_headers, +whose docstring reads "Forward all headers as llm_provider-{header}". Verified +live on the co-op gateway: one chat completion returned 27 such headers, among +them `llm_provider-x-credits-remaining: 16.52` — the co-op's remaining balance +with the provider, readable by any member API key, and (read across calls) its +burn rate. + +This patch makes _get_llm_provider_headers() return {} so no provider header is +copied. get_response_headers() still forwards the four OpenAI-style x-ratelimit-* +headers WHEN a provider sends them; those describe the caller's own request +rather than the co-op's account, and no upstream in our config sends them today. + +Run at BUILD time (the runtime rootfs is read-only under Cloudron). Non-fatal, per +house rule: if upstream moves or rewrites the function, WARN and exit 0 — a +cosmetic leak must never keep the gateway from booting. Regression check: the +daily litellm watcher asserts the gateway returns no `llm_provider-*` header. +""" +import glob +import os +import sys + +ROOT = os.environ.get("COOP_PATCH_ROOT", "/app") +TARGET = "/litellm/litellm_core_utils/llm_response_utils/get_headers.py" +ANCHOR = ''' for k, v in response_headers.items(): + if "llm_provider" not in k: + _key = "{}-{}".format("llm_provider", k) + llm_provider_headers[_key] = v + else: + llm_provider_headers[k] = v + return llm_provider_headers''' +REPLACEMENT = ''' # CO-OP PATCH (2026-10-27): do NOT forward provider headers to callers. + # Upstream prefixes EVERY response header with "llm_provider-", which leaked + # the co-op's remaining provider balance (llm_provider-x-credits-remaining) + # to any member API key. Delete this patch from the Dockerfile to revert. + return {}''' + +candidates = glob.glob(f"{ROOT}/.venv/lib/python*/site-packages{TARGET}") +if not candidates: + candidates = glob.glob(f"{ROOT}/**{TARGET}", recursive=True) + +patched_any = False +for p in candidates: + src = open(p).read() + if "CO-OP PATCH (2026-10-27)" in src: + print(f"INFO: provider-header strip already present in {p}") + patched_any = True + continue + if ANCHOR not in src: + print(f"WARN: anchor not found in {p} (upstream changed it); " + "provider headers are still forwarded (non-fatal)", file=sys.stderr) + continue + new = src.replace(ANCHOR, REPLACEMENT, 1) + try: + compile(new, p, "exec") + except SyntaxError as e: + print(f"WARN: patched source would not compile ({e}); not written " + "(non-fatal)", file=sys.stderr) + continue + with open(p, "w") as f: + f.write(new) + print("[coop-patch] applied: provider response headers are no longer " + f"forwarded to callers ({p})") + patched_any = True + +if not candidates: + print("WARN: get_headers.py not found; provider headers are still forwarded " + "(non-fatal)", file=sys.stderr) +elif not patched_any: + print("WARN: no copy of get_headers.py patched (non-fatal)", file=sys.stderr) + +sys.exit(0)