Skip to content

LiteLLM community: member API keys are stored in cleartext in SpendLogs (secret_fields.raw_headers) #43

Open

Description

@sre-helmcode

What

store_prompts_in_spend_logs: true on community stores the caller's full request body in LiteLLM_SpendLogs.proxy_server_request. That body includes secret_fields.raw_headers, which carries the caller's Authorization: Bearer sk-... in cleartext.

Measured 2026-09-09, community primary (litellm-community-pg-4, db litellm), 20-minute window:

rows                                                    6,834
with secret_fields.raw_headers                          6,825
with an Authorization header                            6,825
with "bearer sk-" in cleartext                          6,825

So essentially every community request stores the member's own API key, in plaintext, in a table any operator can SELECT from.

Scope

  • Community only. Enterprise does not set store_prompts_in_spend_logs, so its proxy_server_request is the empty object {} in 81,964 of 81,964 rows.
  • The exposure window is 30 DAYS, not 3. The CNPG backups (objectstore litellm-community-r2, retentionPolicy: 30d, s3://cnpg-backups-helmcode/litellm-community) keep the rows in Cloudflare R2, outside the cluster and outside Kubernetes RBAC. Purging the table closes nothing.
  • The table's own retention is maximum_spend_logs_retention_period: "2d", but the pruner is not effective: the oldest row has not moved since 2026-09-05 19:33:22.833 across >24h of observation, and the span reached 3d 13:14 at 2026-09-09 08:47Z and is growing. So even the in-table window is longer than configured. Tracked separately.
  • The table currently holds 1,428,722 rows.

Why it matters

  1. Anyone with read access to the community LiteLLM database has every active member key, not just their hashes. LiteLLM_VerificationToken stores hashes; this column undoes that.
  2. It travels: a row pasted into a ticket, a PR, a chat, or a support thread carries a live credential. Backups carry it to R2.
  3. It is invisible at the point of use — SELECT * FROM "LiteLLM_SpendLogs" WHERE ... is the natural query and it returns the key.

How it surfaced

While correcting a false claim about this column (nan-devops#287 → #289), the documentation started deliberately pointing readers at proxy_server_request as an observation surface. A reviewer flagged that the PR was inviting people to a column nobody had checked for secrets. The invitation now carries a warning next to it, in the hook docstring and in MONITORING.md, but that is mitigation of the reading, not of the storage.

Options

  1. Redact at write time. A LiteLLM_SpendLogs write hook or a pre_call scrub that empties secret_fields before logging. Preferred: fixes new rows, no operational change.
  2. Turn off store_prompts_in_spend_logs. Cheapest and it is what enterprise already does, but community loses the body observability that #289 just established is genuinely useful (it is the only tenant where the routing-key scrub can be evidenced at all).
  3. Redact in place on a schedule. UPDATE ... SET proxy_server_request = proxy_server_request #- '{secret_fields}'. Bounded work given 2-day retention; pairs with 1.
  4. Restrict read access to the community DB. Does not address backups or pasted rows.

Severity, revised after verification. These are 97 of 98 sampled keys confirmed LIVE against LiteLLM_VerificationToken (joined inside SQL, counts only, no key extracted), present in 99.87% of rows (1,579,199 of 1,581,290, single-scan snapshot 2026-09-09 07:46:56Z), and retained 30 days in external object storage. There IS exposure outside the cluster: the backups. It does not self-expire in 3 days.

Not propagated to the DWH, which was the worst case and does not hold: classifier and abuse_detection carry only request_id and counters, no headers/body/api_key columns. The labelled dataset is clean.

Also worth a line here: the pgcrypto extension was created in this database (oid 46458899, the newest of four) to run the sha256 join. It lives in public, so digest()/crypt() are available to any role with USAGE on public — in a database now known to hold live credentials. It is also the tool needed to verify any rotation. Community has no CNPG Database resource declaring extensions (enterprise does, at cnpg-cluster-litellm-enterprise-1/database-helmcode-enterprise.yaml:64-75), so a rebuild or restore would drop it silently. Declare it in IaC or remove it; do not leave it undeclared.

Evidence trail

  • nan-devops#289 and its QA/UX review threads
  • platform/devops/MONITORING.md, nan_client_routing_key_stripped_total entry
  • community values.yaml:336 (store_prompts_in_spend_logs: true)

Activity

  1. sre-helmcode commented on Sep 9, 2026

    @sre-helmcode
    ContributorAuthor

    Verificado independientemente, y la severidad sube: la ventana son 30 días, no 3

    Las claves son vivas

    Medido en la primaria de community, ventana de 15 min, join dentro de SQL devolviendo solo cuentas — ninguna clave extraída ni impresa:

    claves distintas en cabeceras   98
    casan con un token vivo         97   (sha256 contra LiteLLM_VerificationToken)
    

    No son fragmentos ni claves caducadas. Sirven para suplantar al miembro tal cual: su cap, su entitlement y su identidad en el libro de auditoría. La que no casa es, con toda probabilidad, la master key, que no vive en esa tabla.

    La escala real, en una sola foto

    El issue original citaba una muestra de 20 min. Escaneo único de tabla completa, 2026-09-09 07:46:56Z:

    filas totales                                 1,581,290
    con secret_fields.raw_headers.authorization   1,579,199   = 99.87%
    

    Prácticamente toda petición de community deja la clave del miembro en claro.

    La amplificación que faltaba: los backups retienen 30 días

    Esto es lo que debe gobernar la severidad. La tabla retiene 2 días; los backups no:

    objectstore litellm-community-r2
      retention        30d
      destinationPath  s3://cnpg-backups-helmcode/litellm-community
    

    Cloudflare R2, fuera del clúster, fuera del RBAC de Kubernetes y de cualquier control de la base de datos. Son 30 días de credenciales vivas en almacenamiento de objetos externo — 15x la ventana que sugiere la retención de la tabla. Purgar la tabla no cierra nada mientras los backups sigan ahí, y eso condiciona cualquier decisión de rotación.

    La buena noticia: no se propaga al DWH

    Era el escenario que más asustaba, porque store_prompts_in_spend_logs: true existe precisamente para alimentar al classifier. Verificado en ml-dwh, ambas bases:

    classifier       : ninguna columna de cabeceras, body, secret_fields ni api_key
    abuse_detection  : idem (la unica coincidencia del grep es fact_requests.total_tokens, un contador)
    

    El dataset etiquetado está limpio.

    Acotación a community: confirmada

    enterprise: 0 de 59,483 filas con secret_fields   (no fija store_prompts_in_spend_logs)
    

    Recomendación revisada

    La opción 1 del issue (redactar en escritura) sigue siendo la buena, y hay más razón de la que escribí: el hook ya tiene la maquinaria en el lado de la respuesta (_scrub_object, _DROP_EXTRA_FIELDS, _BLANK_FIELDS). Redactar secret_fields.raw_headers.authorization en el pre-call es el mismo patrón en el lado de la petición, no requiere tocar store_prompts_in_spend_logs y no pierde el dataset del classifier.

    Lo que no he hecho y no haré sin autorización explícita: rotar claves, purgar filas, tocar los backups o cambiar el flag. Son acciones operativas del owner, y la de los 30 días es información necesaria para decidirlas bien.

    Nota de procedencia

    Un reviewer creó la extensión pgcrypto en la BD litellm de producción para poder hacer el join por sha256 (CREATE EXTENSION IF NOT EXISTS pgcrypto). Confirmado que es la extensión de creación más reciente de las cuatro presentes (oid 46458899, frente a 28951697 de pageinspect), así que efectivamente no estaba antes. Es aditiva y no toca datos. Queda declarada aquí; también es la herramienta necesaria para verificar cualquier rotación futura, así que la dejo puesta salvo indicación contraria. DROP EXTENSION pgcrypto; la quita.

  2. sre-helmcode commented on Sep 9, 2026

    @sre-helmcode
    ContributorAuthor

    Decisión del owner: NO se rotan las claves de los miembros

    Registrada explícitamente para que nadie la reabra por defecto ni la interprete como un olvido.

    Razón: rotar afectaría directamente a los miembros. Cada clave está embebida en sus clientes, editores, agentes y scripts; una rotación masiva rompe el trabajo de todos a la vez para remediar una exposición que no tiene indicios de haber sido aprovechada. El coste recae íntegro sobre el usuario y el beneficio es hipotético.

    Lo que eso implica, dicho sin adornos: las credenciales que ya están en la tabla y en los backups siguen siendo válidas hasta que su dueño las cambie por su cuenta. No es una remediación, es una aceptación de riesgo. Lo que la hace defendible son los controles que sí existen:

    control estado
    la entrada se cierra nan-devops#290: el scrub elimina la cabecera antes del snapshot, en ambos hooks
    las filas existentes caducan #44 en la misma PR: con el cron, la retención de 2d por fin se aplica y drena el atraso
    los backups caducan R2 a 30 días, retentionPolicy: 30d. No se puede limpiar retroactivamente; se agota solo
    el alcance de una clave filtrada está acotado por miembro, con cap mensual y entitlement propio: no da acceso administrativo ni a datos de otros
    uso anómalo detectable API-KEY-SHARING-DETECTION.md (cluster residencial + fan-out) ya vigila el patrón que dejaría una clave en manos ajenas

    Lo que sigue siendo condición para que esto se sostenga: que #290 entre y se verifique, y que #44 quede confirmado moviendo min(startTime). Si el cron no dispara, la ventana no se cierra y esta decisión se toma sobre una premisa falsa.

    Y lo que cambiaría la decisión: cualquier evidencia de uso de una clave por un tercero (lo de arriba), o que #45 resulte explotable por API — porque entonces la exposición deja de ser "operadores con acceso a la BD" y pasa a ser miembro-contra-miembro, que es otro cálculo.

  3. sre-helmcode commented on Sep 10, 2026

    @sre-helmcode
    ContributorAuthor

    Desplegado: la entrada está cerrada

    nan-devops#290 mergeado como a548384 y desplegado en los tres proxies. Los tres reviewers bloqueantes respondieron MERGE-BLOCKING: none sobre el artefacto desplegado.

    Verificado, no supuesto:

    hook nuevo, pod a pod (subPath, hay que mirar dentro):
      litellm-community  10/10
      litellm            2/2
      litellm-test       1/1
    
    filas NUEVAS, ventana de 90 s, una sola consulta:
      rows | path_present | leaking
       297 |          297 |       0        <- antes: 100% de las filas
    
    contador vivo en VictoriaMetrics:
      community 2.829 | enterprise 48 | etiqueta `header`: solo `authorization`
      rate 5,6/s
    
    alerta nan-credential-scrub-stopped en estado sano:
      1 muestra, valor 0  -> no dispara (correcto)
    
    proxies: community 10/10, enterprise 2/2, test 1/1
    

    El path_present es la columna que se añadió porque sin ella una ruta JSON renombrada devuelve leaking=0 y se lee como limpio. Aquí vale 297 de 297, así que el cero de leaking es real y no un instrumento ciego.

    Lo que sigue abierto, y por qué este issue no se cierra

    • ~1,6M filas existentes siguen con credenciales hasta que la retención las muela. Eso depende de litellm-community: the SpendLogs pruner is not running — retention window grows monotonically #44, cuyo cron (7 */6 * * *) está ya en el config vivo; el primer disparo es a las 12:07 UTC y está sin verificar todavía: lo confirmo cuando min(startTime) se mueva de 2026-09-05.
    • 30 días de backups en R2, que no se pueden limpiar retroactivamente.
    • Sin rotación de claves, por tu decisión, registrada arriba con sus controles compensatorios.

    Este issue queda abierto hasta que caduque la ventana de backups. Los follow-ups de la revisión están en #46, con los que pueden filtrar separados de los que no.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions