Skip to content

🔐 feat: Serve Stored Files Through LibreChat With Stable Links - #16914

Open
Vidminas wants to merge 2 commits into
LibreChat-AI:devfrom
Vidminas:feat/stored-file-links
Open

Vidminas wants to merge 2 commits into
LibreChat-AI:devfrom
Vidminas:feat/stored-file-links

Conversation

@Vidminas

@Vidminas Vidminas commented Oct 9, 2026 •

Copy link
Copy Markdown

Pull Request

Before submitting, please review the Contributing Guide.

Documentation changes belong in the LibreChat documentation repository.

👍

Summary

With fileStrategy: "s3", browsers load files from presigned URLs. Those expire after S3_URL_EXPIRY_SECONDS and are stored in messages and file records, so images break (#10269, #10145, #13762). Because browsers fetch from S3 directly, the bucket also can't be kept private to the deployment's network.

STORAGE_PROXY_FILES=true makes storage strategies link files to LibreChat instead: /api/stored-files/<source>/<key>, which never expires. The route streams each object to viewers who may read it. This PR adds the storage-agnostic route and the S3 adapter. Off by default.

Related to #16912. Depends on #16913

How it works

GET /api/stored-files/<source>/<key>
  authenticateViewer      # session cookie, as /images, incl. retired-token and 2FA checks
  sources[source]         # StoredFileSource adapter: parseKey, read, isNotFound
  owner?                  → serve
  other tenant?           → 404
  avatar?                 → serve to any signed-in user (CloudFront cookie scope)
  canAccessFile(record)   → serve (e.g. a file on a shared agent), else 404
packages/api/src/storage/
├── proxy/link.ts       # STORED_FILES_ROUTE, getStoredFileURL, parseStoredFileURL, isStoredFileProxyEnabled
├── proxy/handler.ts    # createStoredFileHandler
└── s3/source.ts        # s3FileSource adapter
api/server/routes/storedFiles.js   # wiring; mounted only when STORAGE_PROXY_FILES is on

With the proxy on, the S3 strategy:

  • stores stored-file links instead of presigned URLs;
  • reads them back to keys, through extractKeyFromS3Url;
  • replaces presigned links where the file list and avatars already renew links;
  • returns no direct download URL, so download and shared-link routes stream through their own checks.

With it off, stored links go back to presigned ones the same way.

Responses: only raster images are sent inline, and every response carries a sandboxing CSP and nosniff.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Performance improvement
  • Breaking change
  • Documentation
  • Translation
  • Tests / tooling / CI

Testing

  1. Set fileStrategy: "s3" and STORAGE_PROXY_FILES=true, and start the server.
  2. Upload an image in a conversation. Its link is /api/stored-files/s3/..., and the browser makes no request to S3.
  3. Wait past S3_URL_EXPIRY_SECONDS (defaults to 2 minutes) and reload. The image still loads.
  4. Check who can open a link:
    • Opened without a session, it returns 401.
    • Opened as another user, it returns 404.
    • Avatars load for every signed-in user.
  5. Downloads and shared links still work.

Tested environments/configuration:

  • OS: macOS 27.0.1 (arm64)
  • Browser: Brave 1.96.61 (Chromium 154), macOS, for manual end-to-end testing of this PR on a deployment (below): images in chat, file previews, downloads and avatars.
  • Storage: S3 on AWS:
    • LibreChat on ECS Fargate, with these commits applied unchanged on top of main at cd5dd94de;
    • Amazon DocumentDB 8.0;
    • sign-in through Amazon Cognito (OpenID);
    • fileStrategy: s3 with STORAGE_PROXY_FILES=true;
    • the bucket refuses object reads and writes from anywhere but the VPC's S3 gateway endpoint, and requires TLS 1.2 or later.

Automated tests:

  • npm run static-checks -- --against upstream/dev --full passes.
  • npm run lighthouse with Node 24.18.0, MongoDB 8.2.1 via mongodb-memory-server 11.0.1, S3 mocked with aws-sdk-client-mock, and Google Chrome 154.0.8037.97 (headless). The medians matched dev: LCP 3,432 ms against 3,455 ms, CLS 0.018 against 0.018, TBT 57 ms against 58 ms.
  • Feature flags: STORAGE_PROXY_FILES on and off.

New tests:

  • proxy/__tests__/handler.test.ts:
    • inline images and the response headers;
    • HEAD requests;
    • attachments;
    • 401 and 403;
    • other users;
    • avatars;
    • tenant isolation, and running the file check in the viewer's tenant context;
    • unknown sources;
    • 404 for missing objects and a bare 500 for other failures;
    • 405 for other methods.
  • proxy/__tests__/link.test.ts.
  • s3/__tests__/source.test.ts: the strategy in both modes, and the adapter (GetObject, HeadObject, missing objects).
  • images/authorization.spec.ts: authenticateViewer.
  • routes/__tests__/storedFiles.spec.js: the real @librechat/api build with mocked S3.

Screenshots / recordings

No user-facing change.

Risk / compatibility

  • Off by default, with no behaviour change while off.
  • Image bytes now pass through the app server. The route streams each object, aborts the S3 read when the client disconnects, and answers HEAD with HeadObject.
  • API clients (OpenAI-compatible and Responses) receive relative stored-file links that need a session, as they already do with local storage and CloudFront.
  • Links stored before the switch: the file list and avatars convert theirs as they load. Messages keep presigned links until the migration added in 🔁 feat: Add a Migration to Stored File Links and Back #16916 runs.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published
  • Required documentation PR: 🔐 docs: Document Serving S3 Files Through LibreChat docs#817

Vidminas and others added 2 commits October 9, 2026 09:50
The fileAccess middleware decided inline whether a user may access a
file: same tenant for tenant-scoped files, then ownership, then access
through the agents the file is attached to. The decision now lives in
canAccessFile(user, file), which the middleware calls and exports, so a
route that already holds a file record can apply the same rules without
going through req.params.file_id.

Behaviour is unchanged; the cross-tenant denial is still logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With fileStrategy s3, browsers load images, previews and downloads
straight from presigned URLs. Those links expire after
S3_URL_EXPIRY_SECONDS and are stored in messages and file records, so
images break once they expire, and the bucket has to answer requests
from the internet, so it cannot be kept private to the deployment's
network (for instance with a bucket policy on aws:SourceVpce).

STORAGE_PROXY_FILES=true makes storage strategies link files to
LibreChat instead: /api/stored-files/<source>/<key>, which never expires.
The route reads the object through the strategy's StoredFileSource
adapter and streams it to viewers who may read it. This commit adds the
storage-agnostic route and links, and the S3 adapter. With the proxy on,
getS3URL returns stored file links, extractKeyFromS3Url reads them back
to keys, presigned links are replaced where the file list and avatars
already renew links, and getS3DownloadURL returns no direct link, so the
download and shared-link routes stream through their own checks. With it
off, stored links go back to presigned ones the same way. Off by
default.

The route signs the viewer in from the session cookie, as /images does,
since an image tag sends no bearer token; authenticateViewer carries the
image route's account checks (retired tokens, required 2FA enrollment).
Within the viewer's tenant it serves the owner named in the key, avatars
to any signed-in user (as CloudFront avatar cookies do), and anyone the
stored file's own rules admit through canAccessFile, such as a file on
an agent shared with the viewer; everyone else gets a 404. The file
lookup is scoped to the key's owner and source. Uploads are now served
from the app's own origin, so only raster images are sent inline; every
response carries a sandboxing CSP and nosniff.

Related to LibreChat-AI#16912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 10:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

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

Labels

🗺️ Backend Infra codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🗺️ Platform Security codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants