SIGN IN SIGN UP

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