Skip to content

🛡️ feat: Azure Blob Storage URL Signing and Refresh Support - #13245

Open
JorgeCosta87 wants to merge 9 commits into
LibreChat-AI:mainfrom
JorgeCosta87:feat/azure-blob-private-container
Open

JorgeCosta87 wants to merge 9 commits into
LibreChat-AI:mainfrom
JorgeCosta87:feat/azure-blob-private-container

Conversation

@JorgeCosta87

@JorgeCosta87 JorgeCosta87 commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds time-limited signed (SAS) URLs and URL refresh for the Azure Blob storage strategy — feature parity with the existing S3 implementation — and closes the security concerns around public Azure Blob containers raised in discussion #6704.

Based on @adamfisher's original PR #11137. That branch has been stale since the Feb 25 review and is currently conflicting with main. This PR continues Adam's work on top of fresh upstream/main, addresses the reviewer comments, and adds a hardening + S3-parity + post-review polish pass on top. Adam is preserved as the original author of the foundational commit.

Why now

Production deployments need private Azure containers. Today's azure_blob strategy:

  • Defaults AZURE_STORAGE_PUBLIC_ACCESS to 'true' in the code (the .env.example says false but a missing env var hits the code-side default), so a misconfigured deploy silently creates world-readable containers.
  • Returns unsigned blockBlobClient.url from every helper, so the only working setup is a public container.

Without this PR, the only "secure" choice is to put a reverse-proxy in front. With it, S3 and Azure behave the same: TTL'd signed URLs, transparent refresh, and no long-lived storage keys in the cluster when Managed Identity is used.

What this PR brings

Foundation (Adam's work — squashed feat: commit, authored as Adam Fisher)

  • getSignedAzureURL: generates a time-limited SAS URL for a blob, choosing the signing method by config:
    • Account Key signing when AZURE_STORAGE_CONNECTION_STRING is set
    • User Delegation SAS via Managed Identity when only AZURE_STORAGE_ACCOUNT_NAME is set (no connection string / long-lived keys in cluster)
  • needsRefreshAzure, getNewAzureURL, refreshAzureFileUrls, refreshAzureUrl: mirror the S3 functions for detecting near-expiry URLs, generating fresh ones, and persisting them.
  • AZURE_EXPIRY_INTERVAL cache key (api/cache/getLogStores.js, packages/data-provider/src/config.ts) — mirrors S3_EXPIRY_INTERVAL.
  • getFileURLRefreshCacheTime in api/server/utils/getFileStrategy.js — shared half-TTL refresh-cache logic for S3 and Azure.
  • Avatar URL refresh dispatched via DI maps in UserController.js and agents/v1.js — adding a new storage provider only requires another map entry. (Addresses @danny-avila's review comment on 6704: Azure Blob Storage URL Signing and Refresh Support #11137.)
  • refreshListAvatars (packages/api/src/agents/avatars.ts) accepts an optional refreshAzureUrl alongside refreshS3Url.

Hardening + S3 parity + post-review polish (three fix: commits)

fix: harden Azure Blob SAS URL signing and flip insecure default

  • Flipped insecure default. AZURE_STORAGE_PUBLIC_ACCESS was destructured with = 'true' at module-load time, so a missing/empty env var silently opened new containers as public. Default is now 'false', and the env var is read off process.env at call time so tests and runtime toggles are honored.
  • SASProtocol.Https on every signed URL. Both signing paths pin protocol: SASProtocol.Https, so a generated SAS can't be replayed over plain HTTP.
  • Fail loud on a malformed connection string. Previously: if (!match || !keyMatch) { logger.warn(...); return blockBlobClient.url; } — a silent unsigned-URL leak. Replaced with a thrown Error.
  • Default URL expiry 120s → 300s. 120s is shorter than typical large-PDF citation previews on slow networks and forces a refresh round-trip on every preview; 300s keeps the blast radius tiny without the churn.
  • Drop redundant dynamic import. getSignedAzureURL did await import('@azure/storage-blob') even though the same module is statically required at the top of the file.
  • Minor cleanup. Dropped a redundant process.env.AZURE_REFRESH_EXPIRY_MS !== null && X check (process.env values are string | undefined, never null).

fix: align Azure Blob refresh parity with S3 and close two filepath bugs

Heavy-review pass against api/server/services/Files/S3/crud.js surfaced three S3-vs-Azure parity gaps that would bite once private containers are enabled:

  • getAzureURL was returning an unsigned blob URL even when private access was configured. This is the strategy interface's getFileURL — its return is persisted as the file's filepath by processFileURL (api/server/services/Files/process.js:252). URL-based uploads (avatars, image-generation outputs) were ending up with unsigned filepaths that 401'd until the next refresh cycle. S3's getS3URL always signs; Azure now matches.
  • deleteFileFromAzure parsed the blob path with file.filepath.split(${AZURE_CONTAINER_NAME}/)[1]. Once filepaths carry SAS query strings, this concatenates the query string into the blob name and the SDK 404s. Also breaks for path-style URLs (Azurite emulator). Switched to extractBlobPathFromAzureUrl, which parses with new URL() so the SAS is discarded and the container is matched as a path segment.
  • extractBlobPathFromAzureUrl itself used a blind "drop the first segment" extraction — for path-style URLs (<host>/<account>/<container>/<path>, used by Azurite and some custom blob endpoints), the container name silently leaked into the blob path. Anchored extraction on the configured container name with a logged fallback for legacy URLs. Mirrors the spirit of S3 PR #11894 (MinIO/R2 path-style support).

Also added a getNewAzureURL guard mirroring getNewS3URL's keyParts.length < 3 check: blob paths shallower than basePath/userId/fileName are malformed and shouldn't be re-signed.

fix: tighten Azure SAS signing path and expand test coverage (new)

Follow-up from a post-PR critical code review. All correctness and observability — no behavior change on the happy path.

  • SAS detection invariant fixed. needsRefreshAzure was keying the "is this a SAS URL" check on searchParams.has('se') (just the expiry). se alone doesn't prove a URL was signed — only sig does. Now keys on sig, mirroring S3's X-Amz-Signature check.
  • BlobServiceClient + credential hoisted to module-level singletons on the Managed Identity path via a new getManagedIdentityBlobServiceClient(accountName) helper. Recreating them per request cold-started the credential's AAD token cache every time — a real perf hotspot on any flow that signs many URLs (file list, agent list refresh). Cache is keyed on account name and invalidates the delegation-key cache on account change.
  • Tightened the credential chain. Production now uses ManagedIdentityCredential explicitly (NODE_ENV === 'production'); DefaultAzureCredential kept for dev so local Azure CLI / VS Code identities still work. Production no longer silently falls through to whichever identity DefaultAzureCredential's chain happens to find.
  • getUserDelegationKey now memoises the in-flight Promise rather than the resolved key. The old resolved-value cache had a benign race where N concurrent first-callers each fired their own AAD round-trip; with promise-caching they all await the same fetch. Rejected promises are dropped from the cache so the next caller retries.
  • getNewAzureURL(currentURL, containerName) accepts an optional containerName and threads it through extractBlobPathFromAzureUrl, getSignedAzureURL, and getAzureContainerClient. Refreshes in multi-container deployments no longer silently rewrite URLs into the env-default container's namespace.
  • Converted await import('@azure/identity') to a top-of-file require(). The dynamic import was a leftover from when the package was treated as optional; it's a declared dep now, and the require form avoids Jest's --experimental-vm-modules requirement for tests.
  • Dropped double-logging in the MI branch of getSignedAzureURL (inner catch logged the same error the outer catch already logs).
  • Restored // Use default concurrency (5) / // Use default buffer size (8MB) inline comments on uploadStream that were dropped earlier.
  • Added URL / file_id context to needsRefreshAzure and refreshAzureUrl log lines — useful when debugging a real outage from logs alone.
  • isNaN → Number.isNaN in the two env-var parsers.

New / changed env vars

# Signing config (either one — Account Key for dev, Managed Identity for prod):
AZURE_STORAGE_CONNECTION_STRING=          # full conn string, Account Key signing
AZURE_STORAGE_ACCOUNT_NAME=               # account name only, User Delegation SAS via Managed Identity

# TTL config:
AZURE_URL_EXPIRY_SECONDS=300              # SAS TTL (default 300s = 5 min). Max 7 days.
AZURE_REFRESH_EXPIRY_MS=                  # optional override of the "is this URL stale" check window

# Container config (unchanged):
AZURE_STORAGE_PUBLIC_ACCESS=false         # NEVER true in production
AZURE_CONTAINER_NAME=files

AZURE_STORAGE_PUBLIC_ACCESS=true is still supported (e.g. for legacy CDN-style deployments), but the default is now false and SAS signing kicks in automatically — no breaking change for existing private-container users, only for users who relied on the undocumented default-public behavior.

Change Type

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Improvements / Enhancements

Testing

cd api && npx jest test/server/services/Files/Azure/crud.test.js --no-coverage
# Tests: 38 passed, 38 total

Test coverage:

  • needsRefreshAzure: SAS-token detection via sig (not se), expired URLs refresh, fresh URLs don't, invalid URLs are treated as needing refresh, public→private transitions work.
  • extractBlobPathFromAzureUrl: virtual-hosted URLs, URLs with SAS tokens, path-style URLs (Azurite-shaped), explicit container override, legacy-container fallback, malformed inputs.
  • getNewAzureURL: returns plain URL when public; returns undefined for malformed blob paths; explicit containerName for multi-container deployments.
  • getSignedAzureURL: throws when no config; throws (vs silently returning unsigned URL) when conn string lacks AccountName/AccountKey; generated SAS contains sig, sp=r, spr=https; default expiry is 300s when AZURE_URL_EXPIRY_SECONDS is unset.
  • getAzureURL: returns a SAS URL when private; returns plain URL when public.
  • deleteFileFromAzure: extracts blob path stripping any SAS query, throws on user-id mismatch, swallows 404, rethrows non-404.
  • refreshAzureFileUrls: no-op on empty input, skips non-azure files, malformed entries don't crash the batch.
  • refreshAzureUrl: null/undefined fileObj, non-azure source passthrough, no-refresh passthrough.
  • Managed Identity signing path: signs via DefaultAzureCredential outside production, via ManagedIdentityCredential in production, and reuses the BlobServiceClient + credential across concurrent signed-URL requests (the singleton invariant — exactly one credential constructed for N concurrent first-callers).

@azure/identity is mocked at file scope so the MI path can be exercised without trying to reach IMDS/AAD; the BlobServiceClient constructor itself is real (no network calls — only getUserDelegationKey is, which the tests spy on).

Also mocks @librechat/data-schemas in the test file so the suite runs in isolation of the workspace's winston format mock.

Test Configuration

  • Node: v24.x (per .nvmrc)
  • npm: >= 10

Commits

2751939fe  feat: Azure Blob Storage URL signing and refresh support              (Adam Fisher)
7e2eaecb4  fix: harden Azure Blob SAS URL signing and flip insecure default      (Jorge Costa)
ad627bbb5  fix: align Azure Blob refresh parity with S3 and close two filepath bugs (Jorge Costa)
8c352770c  fix: tighten Azure SAS signing path and expand test coverage          (Jorge Costa)

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes

adamfisher and others added 7 commits May 22, 2026 03:16
Adds time-limited signed (SAS) URLs and URL refresh for the Azure Blob
storage strategy, achieving feature parity with the existing S3
implementation and addressing the security concerns around public
Azure Blob containers raised in discussion LibreChat-AI#6704.

What this brings
- `getSignedAzureURL` returns a time-limited SAS URL for a blob,
  selecting the signing method based on configuration:
  - Account Key signing when AZURE_STORAGE_CONNECTION_STRING is set
  - User Delegation SAS via Managed Identity when only
    AZURE_STORAGE_ACCOUNT_NAME is set (no long-lived keys in the cluster)
- `needsRefreshAzure`, `getNewAzureURL`, `refreshAzureFileUrls`, and
  `refreshAzureUrl` mirror the S3 functions: detect URLs about to
  expire, generate fresh ones, and persist them.
- `AZURE_EXPIRY_INTERVAL` cache key in `api/cache/getLogStores.js`
  and `packages/data-provider/src/config.ts` (mirrors S3_EXPIRY_INTERVAL).
- `getFileURLRefreshCacheTime` in `api/server/utils/getFileStrategy.js`
  shares the half-TTL refresh-cache logic across S3 and Azure.
- Avatar URL refresh routed via DI maps in UserController.js
  (avatarNeedsRefreshBySource / getNewAvatarUrlBySource) and
  agents/v1.js (urlRefreshersBySource), so adding another storage
  provider only requires another map entry.
- `refreshListAvatars` (packages/api/src/agents/avatars.ts) accepts an
  optional `refreshAzureUrl` alongside `refreshS3Url`.

New env vars (documented in .env.example via a follow-up):
- `AZURE_STORAGE_ACCOUNT_NAME` — enables Managed Identity / User
  Delegation SAS (no connection string required)
- `AZURE_URL_EXPIRY_SECONDS` — TTL for SAS tokens (default 2 minutes)

Based on the work originally proposed in LibreChat-AI#11137 by @adamfisher. This
commit carries his patches forward onto a fresh upstream/main; further
hardening commits follow.

Co-authored-by: Jorge Costa <8352477+JorgeCosta87@users.noreply.github.com>
Refinements on top of LibreChat-AI#11137:

- Flip AZURE_STORAGE_PUBLIC_ACCESS default 'true' -> 'false' so a
  missing/empty env var no longer silently opens public containers.
- Read AZURE_STORAGE_PUBLIC_ACCESS via process.env at call time instead
  of capturing it in a module-load destructure constant, so runtime env
  changes and tests that toggle the flag in beforeEach are honored.
- Pin SAS tokens to SASProtocol.Https on both signing paths (Account Key
  and User Delegation) so signed URLs cannot be replayed over HTTP.
- Replace the silent unsigned-URL fallback when the connection string
  is missing AccountName/AccountKey with a thrown error, so misconfigured
  deployments fail loud instead of leaking unsigned URLs.
- Bump the default URL expiry from 120s to 300s (5 min) in crud.js and
  in getFileURLRefreshCacheTime; 120s is shorter than typical large-PDF
  load times on corporate networks and forces a refresh round-trip on
  every preview.
- Drop the redundant `await import('@azure/storage-blob')` since the
  module is already statically required two lines above; reuse the
  single import everywhere.
- Tidy a redundant `process.env.AZURE_REFRESH_EXPIRY_MS !== null && X`
  env check (process.env values are string | undefined, never null).

Tests: add coverage for the new behavior — throws when neither
connection string nor account name is configured, throws (rather than
returning an unsigned URL) when the connection string parses but lacks
AccountName/AccountKey, SAS tokens include spr=https and sp=r, and the
default expiry is 300s when AZURE_URL_EXPIRY_SECONDS is unset. Also
mock @librechat/data-schemas in the test to keep the suite independent
of the workspace's winston format mock.
Heavy-review pass found three S3-vs-Azure parity gaps that would bite
once private containers are enabled:

1) getAzureURL was returning an unsigned blob URL even when private
   access was configured. It is exposed via the strategy interface as
   getFileURL and its return value is persisted as `filepath` by
   processFileURL, so URL-based uploads (avatars, image-generation
   outputs) ended up with unsigned filepaths that 401'd until the next
   refresh cycle. S3's getS3URL always signs; Azure now matches.

2) deleteFileFromAzure parsed the blob path with
   `file.filepath.split(`${AZURE_CONTAINER_NAME}/`)[1]`. Once filepaths
   carry SAS query strings, this concatenates the query string into the
   blob name and the SDK 404s. Also breaks for path-style URLs (Azurite
   emulator). Switched to `extractBlobPathFromAzureUrl`, which parses
   with `new URL()` so the SAS is discarded and the container is
   matched as a path segment.

3) extractBlobPathFromAzureUrl itself was using a blind "drop the first
   segment" extraction, which for Azurite-style URLs
   (`<host>/<account>/<container>/<path>`) silently leaked the
   container name into the blob path. Anchored extraction on the
   configured container name with a logged fallback to the legacy
   behavior for URLs stored under a previous container name. Mirrors
   the spirit of S3 PR LibreChat-AI#11894 (path-style URL support for MinIO/R2).

Also added a getNewAzureURL guard mirroring getNewS3URL's
`keyParts.length < 3` check: blob paths shallower than
basePath/userId/fileName are malformed and shouldn't be re-signed.

Tests: 6 new cases covering path-style extraction, custom container
override, legacy-container fallback, getNewAzureURL guard, and that
getAzureURL signs when private / returns plain URL when public.

24/24 azure crud tests pass.
Follow-up improvements on top of the SAS signing + refresh chain
already on this branch. All correctness and observability — no
behavior change on the happy path.

crud.js source changes:
- needsRefreshAzure now detects "is this a SAS URL" via `sig` (the
  cryptographic signature) instead of `se` (just the expiry). `se`
  alone doesn't prove a URL was signed; mirrors S3's
  `X-Amz-Signature` check.
- Hoist `BlobServiceClient` + credential to module-level singletons
  on the Managed Identity path via a new `getManagedIdentityBlobServ
  iceClient(accountName)` helper. Recreating them per request
  cold-started the credential's AAD token cache every time — a real
  perf hotspot on any flow that signs many URLs (file list, agent
  list refresh). Cache is keyed on account name and invalidates the
  delegation-key cache on account change.
- Tighten the credential chain: `ManagedIdentityCredential`
  explicitly in production (NODE_ENV === 'production');
  `DefaultAzureCredential` kept for dev so local Azure CLI / VS
  Code identities still work. Production no longer silently falls
  through to whichever identity DefaultAzureCredential's chain
  happens to find.
- `getUserDelegationKey` now memoises the in-flight Promise rather
  than the resolved key. The old resolved-value cache had a benign
  race where N concurrent first-callers each fired their own AAD
  round-trip; with promise-caching they all await the same fetch.
  Rejected promises are dropped from the cache so the next caller
  retries.
- `getNewAzureURL(currentURL, containerName)` accepts an optional
  containerName and threads it through extractBlobPathFromAzureUrl,
  getSignedAzureURL, and getAzureContainerClient. Refreshes in
  multi-container deployments no longer silently rewrite URLs into
  the env-default container's namespace.
- Convert `await import('@azure/identity')` to a top-of-file
  `require()`. The dynamic import was a leftover from when the
  package was treated as optional; it's a declared dep now, and
  the require form avoids Jest's experimental-vm-modules
  requirement for tests.
- Drop the inner try/catch in the MI branch of getSignedAzureURL.
  The outer catch already logs + rethrows; the inner one was
  double-logging the same error.
- Restore `// Use default concurrency (5)` / `// Use default buffer
  size (8MB)` inline comments on uploadStream that were dropped.
- Add URL/file_id context to needsRefreshAzure error log and
  refreshAzureUrl debug + error logs — both useful when debugging
  a real outage from logs alone.
- isNaN → Number.isNaN in the two env-var parsers.

Test coverage expanded from the prior set to 38 cases:
- deleteFileFromAzure: happy path strips SAS from filepath, throws
  on user-id mismatch, swallows 404, rethrows non-404.
- refreshAzureFileUrls: no-op on empty input, skips non-azure
  files, malformed entries don't crash the batch.
- refreshAzureUrl: null/undefined fileObj, non-azure source
  passthrough, no-refresh passthrough.
- getNewAzureURL: containerName override (multi-container support).
- Managed Identity signing path: DefaultAzureCredential outside
  production, ManagedIdentityCredential in production, and
  BlobServiceClient + credential reused across concurrent calls
  (the singleton invariant — exactly one credential constructed
  for N concurrent first-callers).

@azure/identity is mocked at file scope so the MI path can be
exercised without trying to reach IMDS/AAD; the BlobServiceClient
constructor itself is real (no network calls — only
getUserDelegationKey is, which the tests spy on).
Two real bugs surfaced in code review on the fork side that need
mirroring here so the merge is conflict-free for downstream consumers.

1) UserController could persist `undefined` into `user.avatar`.

User avatars are plain strings with no `source` field. The refresh
chain has no way to tell an Azure URL from a Google/OAuth provider
URL or a legacy `/uploads/` path. `needsRefreshAzure` returned
`true` for any non-SAS URL in private mode (the public→private
transition branch), `getNewAzureURL` then returned `undefined`,
and `db.updateUser` overwrote the working avatar with that
`undefined`.

Two-layer fix:
- `crud.js` — new `isAzureBlobUrl(parsedUrl)` host-shape check
  (`*.blob.*`, sovereign clouds, Azurite loopback:10000).
  `needsRefreshAzure` calls it first and returns `false` for
  non-Azure URLs, short-circuiting the refresh chain. Mirrors how
  S3's `needsRefresh` keys on `X-Amz-Signature` — only refresh
  URLs we own.
- `UserController.js` — defensive guard: when `getNewAvatarUrl`
  returns a falsy value, keep the existing avatar (don't persist).
  Belt-and-suspenders in case a future provider's refresh helper
  returns `undefined` for a different reason.

2) Agent-list avatar-refresh cache outlived Azure's URL TTL.

`getListAgentsHandler` cached Azure avatar URLs in the
`S3_EXPIRY_INTERVAL` namespace with `Time.THIRTY_MINUTES`
hardcoded. With Azure's default 5-minute URL TTL, cached entries
served expired SAS URLs for ~25 minutes of every 30-minute cache
window. The avatar-upload handler's cache-invalidate call had the
same problem — it only deleted from the S3 namespace, so a fresh
upload on the Azure path didn't invalidate the stale Azure entry.

Fix:
- New `avatarRefreshCacheKeyByStrategy` map (next to
  `urlRefreshersBySource`) selects the cache namespace by storage
  source: `S3_EXPIRY_INTERVAL` for S3, `AZURE_EXPIRY_INTERVAL`
  for Azure.
- `getListAgentsHandler` reads `req.config?.fileStrategy`, uses
  the per-strategy cache namespace, and uses
  `getFileURLRefreshCacheTime(strategy)` for the cache entry TTL
  (half the URL TTL, floored at 1 minute — 150s for Azure default).
- The avatar-upload handler's cache-invalidate uses the same
  per-strategy key.

Tests (38 → 47 cases):
- `needsRefreshAzure` non-Azure URLs (Google/OAuth, Microsoft
  Graph, legacy /uploads/, S3 stragglers) → `false`.
- `needsRefreshAzure` Azure + Azurite URLs in private mode → `true`
  (public→private transition path preserved).
- `isAzureBlobUrl` host-shape coverage: standard cloud, sovereign
  clouds (US Gov, China), modern *.blob.storage.azure.net,
  Azurite loopback + port 10000, rejects non-Azure hostnames.
`batchUpdateFiles` is shared between `refreshS3FileUrls` and the new
`refreshAzureFileUrls` from this PR, but the post-bulk-write log was
hardcoded to "Updated N files with new S3 URLs" — misleading on the
Azure path. Strategy-neutral wording.

Pure log-text change, no behavior impact.
@JorgeCosta87

Copy link
Copy Markdown
Contributor Author

Hey @danny-avila, could you take a look at this?

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7428559d38

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +129 to +132
await updateUser(userId, { avatar: downloadURL });
}

return url;
return downloadURL;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep manual flag when persisting Azure avatars

Manual avatar protection depends on the ?manual=true marker (see handleExistingUser in api/strategies/process.js, plus the OpenID/SAML strategy checks), but processAzureAvatar now saves and returns downloadURL without that marker. For users on Azure storage, a manually uploaded avatar can therefore be treated as non-manual and overwritten on the next social-login/profile-sync flow. This is a regression in user-visible behavior and should preserve an equivalent manual marker while handling pre-existing query params safely.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 68cb964c7. Restored manual=true on the avatar URL via URL.searchParams.set('manual', 'true') — the SAS query string is preserved and the social-login strategies still see the marker.

Comment on lines +509 to +510
if (!isAzureBlobUrl(url)) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat custom Azure blob hosts as refreshable URLs

needsRefreshAzure exits early for any URL whose hostname does not contain .blob. (or localhost:10000), so Azure Blob URLs served via custom domains will never refresh even if they contain SAS tokens and expire. Since refreshAzureUrl/refreshAzureFileUrls rely on this predicate, deployments using mapped custom blob domains can get permanently stale file/avatar URLs until another write path updates them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 68cb964c7. Added an optional AZURE_STORAGE_BLOB_ENDPOINT env var; when set, URLs whose hostname matches it are treated as Azure Blob URLs by isAzureBlobUrl.

Comment thread api/server/utils/getFileStrategy.js Outdated
Comment on lines +70 to +77
const expirySeconds = parseInt(process.env.S3_URL_EXPIRY_SECONDS, 10) || 120;
// Check for refresh at half the URL expiry time: (expirySeconds * 1000ms) / 2 => expirySeconds * 500
return Math.max(expirySeconds * 500, Time.ONE_MINUTE);
}
if (fileStrategy === FileSources.azure_blob) {
const expirySeconds = parseInt(process.env.AZURE_URL_EXPIRY_SECONDS, 10) || 300;
// Check for refresh at half the URL expiry time: (expirySeconds * 1000ms) / 2 => expirySeconds * 500
return Math.max(expirySeconds * 500, Time.ONE_MINUTE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clamp refresh cache TTL to effective URL expiry

getFileURLRefreshCacheTime uses raw env values (S3_URL_EXPIRY_SECONDS/AZURE_URL_EXPIRY_SECONDS) without the same max bound enforced by the signers (both are capped to 7 days), so an oversized env value can make the refresh-check cache live far longer than the actual signed URL validity. In that configuration, /files and /Agents will skip refresh for an extended interval and keep serving expired signed URLs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 68cb964c7. Env input is now clamped to the same 7-day max the signers enforce: Math.min(raw, MAX_SIGNED_URL_TTL_SECONDS).

Comment thread api/server/controllers/agents/v1.js Outdated
Comment on lines 1022 to 1023
if (urlCache && agent?.id && agent?.avatar && urlCache[agent.id]) {
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict cached avatar URL rewrite to signed-url sources

This cache replay now applies urlCache[agent.id] to any avatar source, but the cache only contains refreshed signed URLs from prior refresh runs. If an agent avatar source changes (for example, to local) after cache creation, list responses can still overwrite it with an old signed URL until cache expiry, returning stale/incorrect avatar links. The earlier source guard prevented this class of stale rewrite.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 68cb964c7. Added a source guard — replay only happens when agent.avatar.source is still s3 or azure_blob.

Comment thread api/server/services/Files/Azure/crud.js Outdated
Comment on lines +85 to +87
_miBlobServiceClient = new BlobServiceClient(
`https://${accountName}.blob.core.windows.net`,
credential,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid hardcoding public-cloud blob endpoint for MI signing

The Managed Identity signing path always constructs BlobServiceClient with https://${accountName}.blob.core.windows.net, which breaks non-public Azure clouds (for example, Gov/China endpoint suffixes) and any deployment using a non-default blob endpoint. In those environments, SAS refresh/signing fails outright even when AZURE_STORAGE_ACCOUNT_NAME is correctly configured.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 68cb964c7. Same AZURE_STORAGE_BLOB_ENDPOINT env var (also used for the isAzureBlobUrl check) overrides the default https://${accountName}.blob.core.windows.net when set.

Five review findings from chatgpt-codex-connector plus the ESLint job:

P1 — Preserve `manual=true` marker in processAzureAvatar
  `samlStrategy` (and the other social-login strategies) skip
  overwriting an avatar when `user.avatar?.includes('manual=true')`.
  Adam's original code appended `?manual=...` to the avatar URL; that
  marker was lost when SAS URLs were introduced. Restored, using the
  URL API so the existing SAS query string is preserved (so the final
  URL carries both `sig=…` and `manual=true`).

P2 — needsRefreshAzure now accepts custom Azure-blob hostnames
  `isAzureBlobUrl` only matched `.blob.` and the Azurite emulator
  endpoint, so Azure Blob URLs behind a custom CNAME never refreshed.
  Added an optional `AZURE_STORAGE_BLOB_ENDPOINT` env var; URLs whose
  hostname matches the configured endpoint count as Azure Blob URLs.

P2 — getFileURLRefreshCacheTime clamps env input to the 7-day max
  Both signers cap `*_URL_EXPIRY_SECONDS` to 7 days. The
  refresh-cache-TTL helper read the raw env value without that clamp,
  so an oversized env value made the cache outlive the actual signed
  URL — `/files` and `/Agents` skipped refresh and served expired
  URLs for the cache window. Same clamp applied here.

P2 — Restrict urlCache replay in agents/v1.js to signed-URL sources
  The avatar `urlCache` is populated by refreshListAvatars, which
  only writes entries for S3 / Azure-Blob avatars. The replay site
  applied the cache entry unconditionally, so an agent whose avatar
  source had changed (e.g. to `local`) could be overwritten with a
  stale signed URL until cache expiry. Added a source guard.

P2 — Managed Identity signing endpoint configurable
  `getManagedIdentityBlobServiceClient` was hardcoded to
  `https://${accountName}.blob.core.windows.net` — only valid for
  the public cloud. Sovereign clouds (Azure Gov, Azure China) and
  custom-domain deployments would fail to sign. Honour the same
  `AZURE_STORAGE_BLOB_ENDPOINT` env var (also used by needsRefreshAzure
  / isAzureBlobUrl above) when set; otherwise default to the
  public-cloud endpoint as before.

ESLint fixes:
  - 3 prettier formatting nits in getLogStores.js, files.js, crud.js
  - Unused `Time` import in agents/v1.js — removed (no longer referenced
    after the cache TTL refactor)

47/47 unit tests still pass. ESLint on the changed files is clean.
getAzureFileStream did an unauthenticated axios.get(fileURL), which 403/409s
on a private container (anonymous access disabled), breaking vision requests
and file preview/download. Mirror getS3FileStream/deleteFileFromAzure: strip
any SAS query, derive the blob path via extractBlobPathFromAzureUrl, and
download through the authenticated container client (Account Key / Managed
Identity). Works whether or not a SAS is present.

encodeAndFormat now fails closed for azure_blob — rethrow on a server-side
download failure instead of falling through to an unfetchable blob URL.
S3/Firebase keep their fall-through.

initializeAzureBlobService honours AZURE_STORAGE_BLOB_ENDPOINT for the
authenticated client too (matching the signing path) so sovereign-cloud /
custom-domain downloads target the right endpoint.

Closes LibreChat-AI#13340.
@JorgeCosta87

Copy link
Copy Markdown
Contributor Author

Pushed a commit adding the authenticated server-side download — the shared backend piece from the discussion on #13411:

  • getAzureFileStream now reads through the authenticated SDK client (Account Key / Managed Identity) instead of an anonymous axios.get, mirroring getS3FileStream/deleteFileFromAzure (strips any SAS, derives the blob path, downloads via the container client). Works with or without a SAS.
  • encodeAndFormat fails closed for azure_blob — no silent fall-through to an unfetchable blob URL.
  • initializeAzureBlobService honours AZURE_STORAGE_BLOB_ENDPOINT on the Managed Identity client too, matching the signing path (sovereign clouds / custom domains).

This makes the PR close #13340 (private-container vision + preview/download) on top of the SAS signing/refresh. Added a getAzureFileStream suite, an encodeAndFormat fail-closed spec, and a cdn/azure endpoint spec — 57 api tests and 2 cdn tests pass.

@danny-avila ready for another look.

@danny-avila

Copy link
Copy Markdown
Collaborator

There are some merge conflicts. Happy to receive some guidance on what the merge order for these Azure Storage PR's should be, too.

rahepler2 added a commit to rahepler2/LibreChat that referenced this pull request Jun 5, 2026
Drop AZURE_STORAGE_ENDPOINT_SUFFIX (env var, azure.ts handling, .env.example
docs, and its test) to consolidate on LibreChat-AI#13245's AZURE_STORAGE_BLOB_ENDPOINT
full-URL override, per review discussion. The backend authenticated download
and the frontend ImageFile display proxy are unchanged.
@mforman

mforman commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Thanks @adamfisher and @JorgeCosta87 for the groundwork here — we needed this fixed and built directly on it.

We've been running a carried-forward version in production and I've pushed it here: mforman/LibreChat:feat/azure-blob-private-container (single commit on current main, v0.8.8-rc1 base).

The main difference from this PR is placement: the signing logic lives in packages/api/src/storage/azure/ as TypeScript, mirroring the S3 layout (storage/s3/) and reusing the shared storage/{types,url,validation,constants} utilities — since S3 was fully migrated out of api/server/services/Files/, this seemed like the shape review was steering toward, and the legacy Files/Azure/ JS is deleted outright with Azure images going through the shared ImageService. Notable behaviors:

  • User delegation SAS via Managed Identity (cached key, ~1 AAD round trip per 6 days) or account-key signing via connection string; HTTPS-only, read-only, blob-scoped; never falls back to an unsigned URL
  • AZURE_STORAGE_PUBLIC_ACCESS defaults to false in code, matching .env.example
  • Inverse of S3's unsigned-URL refresh semantics: legacy public URLs persisted before cutover are detected and re-signed on read, so existing deployments cut over with no migration
  • Refresh dispatch by file.source (refreshFileUrl/refreshFileUrls) wired into /files, user avatars, agent avatars, and share direct downloads alongside S3
  • 18 unit tests + validated in production against a private container (Workload Identity on AKS): uploads, vision, RAG, avatars, shares, legacy-URL re-signing, anonymous reads 409

Happy to open this as a PR if useful, or @JorgeCosta87 feel free to pull any of it into this branch — no attachment to which vehicle lands, we'd just like to stop carrying the patch.

rahepler2 added a commit to rahepler2/LibreChat that referenced this pull request Aug 19, 2026
Drop AZURE_STORAGE_ENDPOINT_SUFFIX (env var, azure.ts handling, .env.example
docs, and its test) to consolidate on LibreChat-AI#13245's AZURE_STORAGE_BLOB_ENDPOINT
full-URL override, per review discussion. The backend authenticated download
and the frontend ImageFile display proxy are unchanged.
@mihidumh

mihidumh commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@danny-avila on your merge-order question from June: four patches now fix the same break (#13245, #13411, #14956, and @mforman's branch). All four contain the same core piece: getAzureFileStream reads through the authenticated container client instead of an anonymous axios.get. A suggestion:

  1. Land the smallest backend-only slice first, so private containers stop failing for everyone: an authenticated getAzureFileStream plus a read-only SAS getDownloadURL. That is what 🔒 fix: Add SAS download URLs for private Azure Blob containers #14956 is (+236/-8, rebased on current main today, HTTPS-only SAS). The equivalent slice of this PR would do the same job.
  2. fix: Azure Blob (private container) image support: authenticated vision downloads + UI display #13411 on top of that for the client display path, rebased.
  3. The full signing + refresh feature as the follow-up: this PR, or @mforman's TypeScript version under packages/api/src/storage/azure/. Since S3 moved out of api/server/services/Files/, the TypeScript layout looks like the shape you want for new backend code, so that may be the better final home, with the JS patches as the stopgap.

If you would rather take one PR only, mforman's branch looks the most complete, and I will close #14956. Happy either way; the goal is to unblock private containers, which #13340 has tracked since May.

@mforman

mforman commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

My patch now sits on top of 0.8.8-rc2 and is running in production without issue

mforman added a commit to mforman/LibreChat that referenced this pull request Sep 23, 2026
Azure Blob storage previously created containers with anonymous public
read (`access: 'blob'`) and served raw unsigned blob URLs to the browser
and model providers, with the code defaulting AZURE_STORAGE_PUBLIC_ACCESS
to true. Setting it to false broke all reads since nothing signed URLs.

- New `packages/api/src/storage/azure/`: HTTPS-only, read-only SAS
  signing via user delegation key (Managed Identity, cached) or account
  shared key (connection string), URL refresh mirroring S3
  (`needsRefreshAzure`, `refreshAzureFileUrls`, `refreshAzureUrl`),
  authenticated server-side download stream, and `getAzureDownloadURL`
  for the `?direct=true` path
- Legacy unsigned URLs persisted before cutover are detected and
  re-signed on read (inverse of S3's unsigned-URL semantics)
- `AZURE_STORAGE_PUBLIC_ACCESS` now defaults to false in code, matching
  .env.example; container creation omits public access when private
- Source-dispatched refresh (`refreshFileUrl`/`refreshFileUrls`) wired
  into /files and agent avatar refresh alongside S3
- `cdn/azure.ts`: static imports, cached DefaultAzureCredential,
  optional AZURE_STORAGE_BLOB_ENDPOINT for sovereign/custom endpoints
- Legacy api/server/services/Files/Azure/ deleted; Azure images now use
  the shared ImageService like S3/CloudFront

Continues the work of adamfisher (LibreChat-AI#11137) and JorgeCosta87 (LibreChat-AI#13245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codegraph-librechat codegraph-librechat Bot added the 🗺️ File Storage codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) label Oct 1, 2026
rahepler2 added a commit to rahepler2/LibreChat that referenced this pull request Oct 2, 2026
Drop AZURE_STORAGE_ENDPOINT_SUFFIX (env var, azure.ts handling, .env.example
docs, and its test) to consolidate on LibreChat-AI#13245's AZURE_STORAGE_BLOB_ENDPOINT
full-URL override, per review discussion. The backend authenticated download
and the frontend ImageFile display proxy are unchanged.
@codegraph-librechat codegraph-librechat Bot added 🗺️ Server Core codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🗺️ Backend Infra codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) labels Oct 3, 2026
mforman added a commit to mforman/LibreChat that referenced this pull request Oct 5, 2026
Azure Blob storage previously created containers with anonymous public
read (`access: 'blob'`) and served raw unsigned blob URLs to the browser
and model providers, with the code defaulting AZURE_STORAGE_PUBLIC_ACCESS
to true. Setting it to false broke all reads since nothing signed URLs.

- New `packages/api/src/storage/azure/`: HTTPS-only, read-only SAS
  signing via user delegation key (Managed Identity, cached) or account
  shared key (connection string), URL refresh mirroring S3
  (`needsRefreshAzure`, `refreshAzureFileUrls`, `refreshAzureUrl`),
  authenticated server-side download stream, and `getAzureDownloadURL`
  for the `?direct=true` path
- Legacy unsigned URLs persisted before cutover are detected and
  re-signed on read (inverse of S3's unsigned-URL semantics)
- `AZURE_STORAGE_PUBLIC_ACCESS` now defaults to false in code, matching
  .env.example; container creation omits public access when private
- Source-dispatched refresh (`refreshFileUrl`/`refreshFileUrls`) wired
  into /files and agent avatar refresh alongside S3
- `cdn/azure.ts`: static imports, cached DefaultAzureCredential,
  optional AZURE_STORAGE_BLOB_ENDPOINT for sovereign/custom endpoints
- Legacy api/server/services/Files/Azure/ deleted; Azure images now use
  the shared ImageService like S3/CloudFront

Continues the work of adamfisher (LibreChat-AI#11137) and JorgeCosta87 (LibreChat-AI#13245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rahepler2 added a commit to rahepler2/LibreChat that referenced this pull request Oct 6, 2026
Drop AZURE_STORAGE_ENDPOINT_SUFFIX (env var, azure.ts handling, .env.example
docs, and its test) to consolidate on LibreChat-AI#13245's AZURE_STORAGE_BLOB_ENDPOINT
full-URL override, per review discussion. The backend authenticated download
and the frontend ImageFile display proxy are unchanged.
@codegraph-librechat codegraph-librechat Bot added 🗺️ Platform Security codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🗺️ Core Infra & Sec codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) labels Oct 9, 2026

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) 🗺️ Core Infra & Sec codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🗺️ File Storage 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 🗺️ Server Core codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants