fix: LibreChat 0.8.5 compatibility, cross-user file-leak, JWT auth, all-runtimes bash image (#66)
* fix(security): prevent cross-user file leak via session reuse
Sessions referenced by file_ref.session_id or entity_id are now reused
only when the existing session's metadata.user_id matches the request's
user_id. Before this change, a request from user-B carrying a file_ref
that pointed at user-A's upload session would execute *in* user-A's
session, where _mount_files would then re-hydrate every file ever
uploaded under that session into user-B's pod (issue surfaced in
multiple user reports of "users seeing other users files").
Defense-in-depth in _mount_files: even after the session-isolation
fix routes user-B to a fresh execution session, refuse to read file
content out of a session owned by a different user. Legacy sessions
without metadata.user_id are still readable for back-compat — the
window pre-dates the ownership concept.
license; semantics preserved, doc strings rewritten for our codebase).
Pre-existing TestMountFilesExtended tests were updated to set
ctx.session_id, which they had been implicitly relying on; the realistic
"upload-then-exec in same session" scenario short-circuits the new
authorization guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(auth): accept HTTP Basic auth, add AUTH_ENABLED bypass
LibreChat 0.8.5 (@librechat/agents >= 3.1.74) removed the x-api-key
header and the body-spread LIBRECHAT_CODE_API_KEY field from its code-
interpreter client. Users with the legacy URL-credentials configuration
(LIBRECHAT_CODE_BASEURL=https://KEY@host/v1) now reach us with the key
in an Authorization: Basic header that axios derives from the URL —
which our middleware was ignoring, causing every request to 401.
Extractor now accepts:
- x-api-key (preferred, unchanged)
- Authorization: Bearer / ApiKey (unchanged)
- Authorization: Basic base64(KEY:) ← new; LC convention is user-half
- Authorization: Basic base64(:KEY) ← also accepted; falls through
- Authorization: Basic base64(user:KEY) → returns user (documented)
Malformed Basic headers return None (no 500). x-api-key still wins so
reverse-proxy injection has deterministic behavior.
New AUTH_ENABLED setting (default true) lets operators with their own
trust boundary (mTLS sidecar, VPC ingress) globally bypass key auth on
user paths. Admin endpoints (/api/v1/admin) still require MASTER_API_KEY
regardless — the bypass is intentionally gated. Bypassed requests get
scope["state"] seeded with anonymous markers so downstream metrics code
keeps working without crashing on missing api_key_hash.
_should_skip_auth gained a `scope` parameter to perform that seeding.
Three legacy callers in test_trusted_networks.py updated to pass {}.
Same change applied to the older AuthenticationMiddleware in
middleware/auth.py for consistency; main.py uses SecurityMiddleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): wire LibreChat User-Id header into session ownership
LibreChat 0.8.5 sends the user identifier in the User-Id HTTP header on
every code-interpreter call (api/server/services/Files/Code/crud.js).
It is NOT in the request body. Without consuming the header, every
LibreChat request reaches us with request.user_id=None, which after the
session-isolation fix forces the orchestrator into "create new session"
on every call — breaking same-user file continuity (upload → exec → next
exec all land in separate sessions).
Changes:
POST /exec — exec.py copies the User-Id (or X-User-Id) header onto
request.user_id when the body field is missing. Body wins when both
are set, preserving direct-API compatibility.
POST /upload — upload_file gained User-Id / X-User-Id Header params.
When present, the value is persisted onto session.metadata.user_id at
session-creation time, AND the entity_id reuse path now matches the
requesting user (mirrors the orchestrator's same-user gate). Without
this, the upload-side entity_id reuse was its own cross-user collapse
vector: two users uploading via the same shared agent would converge
on a single session.
Pre-existing tests in test_cross_session_files.py modeled the legacy
issue-#34 entity_id=null scenario. They now explicitly configure
session_service.get_session to return None so the orchestrator treats
the upload sessions as legacy/anonymous (allowed for back-compat). The
upload reuse test was split into same-user-reuse (must reuse) and
cross-user-must-not-reuse cases, and a third test pins that User-Id
is persisted onto session metadata.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): LibreChat 0.8.5 wire contract — storage_session_id, kind, batch
LibreChat 0.8.5 (@librechat/agents >= 3.1.74) and the LC PRs around
the codeapi auth refactor (danny-avila/LibreChat#13028, #12767) shifted
the upload/exec wire contract in ways that broke our clients:
1. Responses are expected to carry ``storage_session_id`` (renamed
from ``session_id``) on upload responses and per-file entries.
packages/data-provider/src/codeEnvRef.ts and
api/server/services/Files/Code/crud.js both read this field; a
missing key was making LC throw "Unexpected batch upload response".
2. File references gained ``resource_id``, ``kind`` ('skill'|'agent'|
'user'), and ``version`` discriminator fields (CodeEnvFile type).
LC fans them through to skill priming, sandbox file routing, and
tool-output references.
3. ``POST /upload/batch`` is the endpoint LC uses for skill bundles —
uploading many files in one request. We didn't have it, so skill
priming silently 404'd.
4. ``GET /files/{session_id}`` is called with ``kind``/``id``/
``version`` query params by fetchSessionFiles. Without accepting
them the request 422'd.
Changes:
src/models/exec.py
- FileRef: populate_by_name; resource_id/kind/version added;
storage_session_id computed-field mirrors session_id.
- RequestFile: storage_session_id accepted via AliasChoices alongside
legacy session_id; resource_id/kind/version added.
src/api/files.py
- POST /upload response: dual-emit storage_session_id + session_id.
- POST /upload/batch: new endpoint. Returns per-file succeeded/failed
counts; persists kind/resource_id on session.metadata when present.
- GET /files/{session_id}: kind/id/version query params accepted
(pass-through; no server-side filter today). Each list item gets
both storage_session_id and session_id.
tests/unit/test_librechat_contract.py
- 16 new tests pinning every contract point above so a future model
refactor cannot silently regress LC compatibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(auth): CodeAPI JWT verifier (LibreChat 0.8.5 signer side)
LibreChat danny-avila/LibreChat#13028 introduced signed JWT auth for
the code-interpreter API. Tokens are minted by LC's
packages/api/src/auth/codeapi.ts (EdDSA Ed25519 by default, RS256
supported) and sent as `Authorization: Bearer <jwt>` with claims
{iss, aud, sub, iat, nbf, exp, jti, tenant_id, role,
principal_source, auth_context_hash}.
This commit adds the verifier side as a new service module —
src/services/codeapi_jwt.py — and the env knobs to configure it. The
middleware wiring lands in a follow-up commit so the verifier can be
unit-tested in isolation first.
Settings (env names match LC's signer 1:1):
- CODEAPI_JWT_ENABLED (default false; opt-in)
- CODEAPI_JWT_PUBLIC_KEY (PEM, raw JWK JSON, or file path)
- CODEAPI_JWT_ALGORITHM (EdDSA | RS256; default EdDSA)
- CODEAPI_JWT_ISSUER (default 'librechat')
- CODEAPI_JWT_AUDIENCE (default 'codeapi')
- CODEAPI_JWT_LEEWAY_SECONDS (default 10; clock-skew tolerance)
- CODEAPI_JWT_TRUST_TENANT_ID (default false; observability-only)
Security:
- Algorithm is pinned to the operator-configured value — never the
JWT header's `alg`. Standard alg-confusion defence; an attacker
hand-crafting an HS256 token signed with the public key as the
HMAC secret is rejected.
- iss/aud are checked exactly; exp/nbf get the configured leeway.
- require=[iss, aud, sub, exp, iat] so a token missing any of those
is rejected without falling through to defaults.
- Public key loaded once and process-cached; rotate by restart.
CodeApiJwtConfigurationError vs CodeApiJwtError distinction lets the
caller map config bugs to 500 and token problems to 401.
22 unit tests in tests/unit/test_codeapi_jwt.py cover: EdDSA + RS256
happy path, expired / wrong iss / wrong aud / wrong key / empty sub /
malformed / alg-confusion / missing-required-claim rejection, and
PEM / JWK / file-path key formats.
New dependency: pyjwt[crypto]>=2.10.0 (pulls in cryptography).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(auth): wire CodeAPI JWT verification into SecurityMiddleware
The CodeAPI JWT verifier landed in the previous commit as a standalone
service. This commit makes the middleware actually use it.
Auth-path priority on user-facing endpoints:
1. ``Authorization: Bearer <jwt>`` AND ``codeapi_jwt_enabled``
AND the token structurally looks like a JWT (3 base64 segments)
→ run the JWT verifier.
- Valid: seed scope["state"] with authenticated=True,
user_id=<sub>, api_key_hash="jwt:<sub-hash-prefix>",
auth_principal_source="codeapi_jwt", and (when
codeapi_jwt_trust_tenant_id is true) tenant_id=<tenant>.
The legacy API-key path is SKIPPED.
- Invalid: 401 immediately. DO NOT fall back to API-key auth
with the same Bearer string. This is the downgrade-attack
defence — an attacker mustn't be able to bypass JWT auth by
submitting a deliberately-bad JWT and having the server quietly
try the same string as an API key.
- Configuration error (JWT enabled but no public key): 500.
The client did nothing wrong; we just can't verify.
2. Otherwise: existing API-key path (x-api-key → Bearer api-key →
ApiKey scheme → Basic → JSON body LIBRECHAT_CODE_API_KEY).
The "looks like a JWT" structural check (3 dot-separated segments,
each ≥4 chars) lets a plain API key submitted as ``Bearer`` keep
working — we only divert to the JWT path for tokens that could
plausibly be a JWT.
12 new unit tests in test_security_middleware.py cover:
- _extract_bearer_jwt: disabled / wrong scheme / non-JWT-shaped /
happy path classification.
- _authenticate_jwt: valid token seeds expected state; tenant_id
omitted when trust is off; CodeApiJwtError → 401; configuration
error → 500.
- End-to-end downgrade test: bad JWT 401s and the inner app is
NEVER reached with the same Bearer treated as an api-key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): trust JWT.sub over User-Id header in exec + upload
When SecurityMiddleware verified a CodeAPI JWT, request.state.user_id
is set to the JWT's `sub` claim — cryptographically authenticated.
Both /exec and /upload now use a 3-tier resolution chain:
1. JWT.sub (request.state.user_id) — wins over everything else.
2. ExecRequest.user_id body field — direct-API integration.
3. User-Id / X-User-Id HTTP header — LibreChat 0.8.5 convention.
The JWT override is the security-critical bit: without it, an
attacker holding a valid JWT for user-A could pass user_id=victim
in the body (or User-Id: victim in headers) and the orchestrator's
cross-user-isolation guard would happily route them into the
victim's sessions. The signed JWT is the source of truth.
upload_file and upload_files_batch gain a `request: Request`
parameter to access scope state; existing tests had to be updated
to inject an anonymous mock_http_request fixture (resolution falls
through to headers exactly as before).
3 new tests in test_api_exec.py pin the JWT precedence:
- JWT.sub overrides body user_id (attacker submits user_id=victim)
- JWT.sub overrides User-Id header (same threat via header)
- (existing tests cover header-fallback when JWT absent)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document AUTH_ENABLED and CODEAPI_JWT_* env vars
- .env.example: stub-out the new auth toggles (commented; off by default)
with one-line rationale each.
- docs/CONFIGURATION.md: full Authentication section update including
the auth source priority order, a new CodeAPI JWT Authentication
subsection covering the LC 0.8.5 integration (env-var-by-env-var
mapping to LC's signer side), the trust model (JWT.sub overrides
body/header user_id), and key rotation guidance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: fmt
* feat(docker): bake every supported language into the bash image
LibreChat @librechat/agents >= 3.1.74 collapsed `execute_code` and all
per-language code-interpreter tools into a single `bash_tool`. The LC
client no longer sends `lang: py | js | go | ...` — every code-interpreter
call now arrives as `lang: bash`, and the model is expected to shell out
to `python3 -c "..."`, `node -e "..."`, `go run`, etc. from inside the
shell. Before this change, those calls would fail in the bash sandbox
because the interpreters / compilers weren't installed.
Bakes all 13 supported language runtimes into docker/bash.Dockerfile:
bash, sh — bash + coreutils + jq
python (+ data stack) — python3 + python-is-python3 + numpy /
pandas / matplotlib / openpyxl / Pillow
javascript, typescript — nodejs + npm + typescript (global)
go — golang-go
java — default-jdk-headless (OpenJDK 21)
c, cpp — gcc, g++
php — php-cli
rust — rustc, cargo
r — r-base-core
fortran — gfortran
d — ldc (compiler) + gcc (linker)
Final image is ~865 MB vs ~100 MB for the bash-only image — the trade
made deliberately so the bash pod can serve every LC request rather
than erroring on a missing interpreter.
ENTRYPOINT pins HOME=/tmp and per-toolchain cache dirs (GOCACHE,
GOPATH, CARGO_HOME, RUSTUP_HOME, JAVA_TOOL_OPTIONS=-Duser.home=/tmp,
MPLCONFIGDIR) so compilers don't try to write build artefacts into
the read-only sandbox home or the user-code /mnt/data dir.
The dedicated per-language images (python, nodejs, go, etc.) still
ship and are still served by `/exec` when callers send an explicit
`lang: <code>`. Only the LC client path changed.
Validation: scripts/test-bash-megaimage.sh runs the runner's exact
compile-and-run command (from docker/runner/executor.go LangSpec.Args)
for each of the 13 languages, asserts stdout contains "Hello, World!",
and reports pass/fail per language. All 13 pass on first run against
the locally-built image.
Side effect: the new BASE_IMAGE build arg lets CI / local builds
swap dhi.io/debian-base for debian:trixie-slim without editing the
Dockerfile. Production default unchanged.
Drive-by: removed an internal-fork reference from a code comment in
src/services/orchestrator.py — the algorithm is documented inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> A
Aron committed
99a692694073fa94e1fe0385129ff7558142ab7f
Parent: d792596
Committed by GitHub <noreply@github.com>
on 5/20/2026, 2:25:55 PM