From b155debc3bca7faf8e9f85ce27550ebcb97c465c Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:24:06 -0700 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=94=90=20fix:=20Stop=20deriving=20the?= =?UTF-8?q?=20exchange=20Origin=20from=20the=20referer=20on=20SSO=20callba?= =?UTF-8?q?cks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OAuth exchange request reaches the panel without an Origin header (proxies or clients that drop it), the referer identifies the initiating page or IdP rather than the panel, and multi-hop proxy chains produce comma-joined x-forwarded-proto values that parse into an invalid origin. Either way LibreChat's exchange-code origin binding rejects the code with INVALID_OR_EXPIRED_CODE, shown as "Authorization code has expired. Please try again." despite successful IdP authentication. Resolve the panel's own origin from the Origin header when present, otherwise from host plus the first x-forwarded-proto value, and never from the referer. Verified end to end against a stub implementing LibreChat's exchange contract: with an IdP referer and no Origin, main forwards the IdP origin and fails; this branch forwards the panel origin and completes login, including the full browser SSO flow. --- src/server/auth.oauth.test.ts | 51 +++++++++++++++++++++++++++++++++++ src/server/auth.ts | 22 ++++++++------- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 569f4803..969deb05 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -243,6 +243,57 @@ describe('oauthExchangeFn', () => { ); }); + it('derives the exchange Origin from the serving host when the callback carries the IdP referer', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('referer', 'https://login.microsoftonline.com/'); + requestHeaders.set('host', 'example.com'); + requestHeaders.set('x-forwarded-proto', 'https'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + const result = await oauthExchangeFn({ data: { code: 'c'.repeat(64) } }); + + expect(result).toEqual({ + error: false, + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }); + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: 'c'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + + it('uses the first x-forwarded-proto value when the proxy chain appends multiple', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('host', 'example.com'); + requestHeaders.set('x-forwarded-proto', 'https, http'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: 'd'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: 'd'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => { sessionState.data = {}; diff --git a/src/server/auth.ts b/src/server/auth.ts index 8e7172b2..067f6be1 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -21,23 +21,25 @@ function extractCookieValue(response: Response, name: string): string | undefine return undefined; } +/** + * Resolves the admin panel's own origin for LibreChat's exchange-code origin binding. + * + * Never derived from the `referer` header: when a proxy or client drops the `Origin` + * header, the referer identifies whatever page or IdP initiated the request (e.g. + * Azure EntraID's login.microsoftonline.com on an IdP-initiated callback), not the + * panel itself. Forwarding a foreign origin makes LibreChat reject the exchange code + * as expired even though authentication succeeded. The panel's own serving origin is + * always derivable from `host` plus the first `x-forwarded-proto` value. + */ function getRequestOrigin(): string | undefined { const origin = getRequestHeader('origin'); if (origin) return origin; - const referer = getRequestHeader('referer'); - if (referer) { - try { - return new URL(referer).origin; - } catch { - return undefined; - } - } - const host = getRequestHeader('host'); if (!host) return undefined; - const proto = getRequestHeader('x-forwarded-proto') ?? 'http'; + const forwardedProto = getRequestHeader('x-forwarded-proto'); + const proto = forwardedProto?.split(',')[0]?.trim() || 'http'; return `${proto}://${host}`; } From fcca14cadd94166f8d3c6d4f297aa30e3d733454 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:32:54 -0700 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=80=20fix:=20Prefer=20x-forwarded-?= =?UTF-8?q?host=20when=20deriving=20the=20exchange=20Origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a reverse proxy rewrites Host to the internal upstream authority (for example admin-panel:3000) while preserving the browser-visible authority in X-Forwarded-Host, the host fallback constructed an internal origin that LibreChat's exchange-code origin binding rejects. getRequestOrigin now takes the first x-forwarded-host value over host, mirroring the existing x-forwarded-proto handling. --- src/server/auth.oauth.test.ts | 48 +++++++++++++++++++++++++++++++++++ src/server/auth.ts | 9 ++++--- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 969deb05..b4adaf0a 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -294,6 +294,54 @@ describe('oauthExchangeFn', () => { }); }); + it('prefers x-forwarded-host over a proxy-rewritten upstream host', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('host', 'admin-panel:3000'); + requestHeaders.set('x-forwarded-host', 'example.com'); + requestHeaders.set('x-forwarded-proto', 'https'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: 'e'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: 'e'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + + it('uses the first x-forwarded-host value when the proxy chain appends multiple', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('host', 'admin-panel:3000'); + requestHeaders.set('x-forwarded-host', 'example.com, internal-lb.local'); + requestHeaders.set('x-forwarded-proto', 'https'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: 'f'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: 'f'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => { sessionState.data = {}; diff --git a/src/server/auth.ts b/src/server/auth.ts index 067f6be1..ab8cf8fe 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -28,14 +28,17 @@ function extractCookieValue(response: Response, name: string): string | undefine * header, the referer identifies whatever page or IdP initiated the request (e.g. * Azure EntraID's login.microsoftonline.com on an IdP-initiated callback), not the * panel itself. Forwarding a foreign origin makes LibreChat reject the exchange code - * as expired even though authentication succeeded. The panel's own serving origin is - * always derivable from `host` plus the first `x-forwarded-proto` value. + * as expired even though authentication succeeded. The panel's browser-visible origin + * is derived from forwarding metadata instead: the first `x-forwarded-host` value wins + * over `host` because Host-rewriting proxies replace `host` with the internal upstream + * authority, then the first `x-forwarded-proto` value supplies the scheme. */ function getRequestOrigin(): string | undefined { const origin = getRequestHeader('origin'); if (origin) return origin; - const host = getRequestHeader('host'); + const forwardedHost = getRequestHeader('x-forwarded-host'); + const host = forwardedHost?.split(',')[0]?.trim() || getRequestHeader('host'); if (!host) return undefined; const forwardedProto = getRequestHeader('x-forwarded-proto'); From ca39b0073a870d9210efbb01b58c08209be50242 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:16:02 -0700 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20Recover=20the=20schem?= =?UTF-8?q?e=20from=20a=20same-host=20referer=20when=20x-forwarded-proto?= =?UTF-8?q?=20is=20absent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaulting the scheme to http when both the Origin header and x-forwarded-proto are missing broke HTTPS deployments whose proxy strips Origin without forwarding proto metadata. getRequestOrigin now uses the referer's origin in that case, but only when the referer host matches the serving host, so a foreign referer such as an IdP origin can never be forwarded as the panel's own origin. --- src/server/auth.oauth.test.ts | 46 +++++++++++++++++++++++++++++++++++ src/server/auth.ts | 36 ++++++++++++++++++--------- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index b4adaf0a..02876757 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -342,6 +342,52 @@ describe('oauthExchangeFn', () => { }); }); + it('recovers the https scheme from a same-host referer when x-forwarded-proto is absent', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('host', 'example.com'); + requestHeaders.set('referer', 'https://example.com/admin/auth/openid/callback?code=abc'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: '1'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: '1'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + + it('never adopts a foreign referer origin even when x-forwarded-proto is absent', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('host', 'example.com'); + requestHeaders.set('referer', 'https://login.microsoftonline.com/'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: '2'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'http://example.com', + }, + body: JSON.stringify({ code: '2'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => { sessionState.data = {}; diff --git a/src/server/auth.ts b/src/server/auth.ts index ab8cf8fe..5edfcd96 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -21,17 +21,29 @@ function extractCookieValue(response: Response, name: string): string | undefine return undefined; } +/** Returns the referer's origin only when its host matches the serving host, so a foreign referer (e.g. an IdP such as Azure EntraID's login.microsoftonline.com) can never be forwarded as the panel's own origin. */ +function getSameHostRefererOrigin(host: string): string | undefined { + const referer = getRequestHeader('referer'); + if (!referer) return undefined; + try { + const url = new URL(referer); + return url.host.toLowerCase() === host.toLowerCase() ? url.origin : undefined; + } catch { + return undefined; + } +} + /** - * Resolves the admin panel's own origin for LibreChat's exchange-code origin binding. + * Resolves the admin panel's browser-visible origin for LibreChat's exchange-code + * origin binding. * - * Never derived from the `referer` header: when a proxy or client drops the `Origin` - * header, the referer identifies whatever page or IdP initiated the request (e.g. - * Azure EntraID's login.microsoftonline.com on an IdP-initiated callback), not the - * panel itself. Forwarding a foreign origin makes LibreChat reject the exchange code - * as expired even though authentication succeeded. The panel's browser-visible origin - * is derived from forwarding metadata instead: the first `x-forwarded-host` value wins - * over `host` because Host-rewriting proxies replace `host` with the internal upstream - * authority, then the first `x-forwarded-proto` value supplies the scheme. + * Order: the `origin` header, then the first `x-forwarded-host` value (Host-rewriting + * proxies replace `host` with the internal upstream authority) combined with the first + * `x-forwarded-proto` value. When no forwarded proto is available, a same-host referer + * recovers the scheme for HTTPS deployments whose proxy strips `Origin` without setting + * `x-forwarded-proto`. A foreign referer is never used: it identifies whatever page or + * IdP initiated the request, and forwarding it makes LibreChat reject the exchange code + * as expired even though authentication succeeded. */ function getRequestOrigin(): string | undefined { const origin = getRequestHeader('origin'); @@ -42,8 +54,10 @@ function getRequestOrigin(): string | undefined { if (!host) return undefined; const forwardedProto = getRequestHeader('x-forwarded-proto'); - const proto = forwardedProto?.split(',')[0]?.trim() || 'http'; - return `${proto}://${host}`; + const proto = forwardedProto?.split(',')[0]?.trim(); + if (proto) return `${proto}://${host}`; + + return getSameHostRefererOrigin(host) ?? `http://${host}`; } export const adminLoginFn = createServerFn({ method: 'POST' }) From 5078f2cedbaacdae4ee40ad8c0651158b2c4ccb7 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:24:17 -0700 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20feat:=20Add=20ADMIN=5F?= =?UTF-8?q?PANEL=5FPUBLIC=5FURL=20as=20an=20authoritative=20exchange=20Ori?= =?UTF-8?q?gin=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployments behind proxies that strip the Origin header and forward no x-forwarded-proto or x-forwarded-host metadata had no deterministic way to satisfy LibreChat's exchange-code origin binding. When ADMIN_PANEL_PUBLIC_URL is set, getRequestOrigin now sends its origin directly, before any header derivation, and operators can mirror LibreChat's ADMIN_PANEL_URL value. The header chain (origin, forwarded host and proto, same-host referer) is unchanged as the fallback, and a malformed value is warned about and ignored. --- .env.example | 6 ++++ README.md | 1 + src/server/auth.oauth.test.ts | 62 +++++++++++++++++++++++++++++++++++ src/server/auth.ts | 30 +++++++++++++---- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a62d10f4..b6b3b439 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ SESSION_SECRET= # Useful when the server reaches LibreChat on a different URL than the browser. # API_SERVER_URL=http://localhost:3080 +# Browser-facing URL of the admin panel itself. When set, its origin is sent to +# LibreChat's OAuth exchange instead of deriving it from request headers. Only +# needed when a proxy strips the Origin header and forwards no x-forwarded-proto +# or x-forwarded-host metadata. Mirror LibreChat's ADMIN_PANEL_URL value. +# ADMIN_PANEL_PUBLIC_URL=https://example.com/admin + # Force SSO-only login (hides the email/password form) # ADMIN_SSO_ONLY=false diff --git a/README.md b/README.md index 508c60d7..3cbf6398 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ docker compose down # stop | `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:` in Docker | | `VITE_BASE_PATH` | No | `/` | URL subpath to serve the panel under (e.g., `/adminpanel`). Must match at build time and runtime | | `API_SERVER_URL` | No | Falls back to `VITE_API_BASE_URL` | Server-side LibreChat API URL when the container reaches LibreChat differently than the browser | +| `ADMIN_PANEL_PUBLIC_URL` | No | Derived from request headers | Browser-facing panel URL; set when a proxy strips `Origin` and forwards no proto/host metadata | | `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only | | `ADMIN_SSO_ENABLED` | No | `true` | Set `false` to hide the SSO button (and auto-redirect) while keeping email/password login | | `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms | diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 02876757..014402cb 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -197,6 +197,8 @@ describe('verifyAdminTokenFn', () => { }); describe('oauthExchangeFn', () => { + const originalPublicUrl = process.env.ADMIN_PANEL_PUBLIC_URL; + beforeEach(() => { fetchMock.mockReset(); updateSession.mockReset(); @@ -204,6 +206,66 @@ describe('oauthExchangeFn', () => { sessionState.data = {}; requestHeaders.clear(); vi.stubGlobal('fetch', fetchMock); + delete process.env.ADMIN_PANEL_PUBLIC_URL; + }); + + afterEach(() => { + if (originalPublicUrl === undefined) delete process.env.ADMIN_PANEL_PUBLIC_URL; + else process.env.ADMIN_PANEL_PUBLIC_URL = originalPublicUrl; + }); + + it('sends the configured ADMIN_PANEL_PUBLIC_URL origin ahead of any header derivation', async () => { + process.env.ADMIN_PANEL_PUBLIC_URL = 'https://public.example.com/admin'; + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('origin', 'http://other.test'); + requestHeaders.set('host', 'admin-panel:3000'); + requestHeaders.set('x-forwarded-host', 'internal-lb.local'); + requestHeaders.set('x-forwarded-proto', 'http'); + requestHeaders.set('referer', 'https://login.microsoftonline.com/'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: '3'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://public.example.com', + }, + body: JSON.stringify({ code: '3'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + + it('falls back to header derivation when ADMIN_PANEL_PUBLIC_URL is malformed', async () => { + process.env.ADMIN_PANEL_PUBLIC_URL = 'not a url'; + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('origin', 'https://example.com'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: '4'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: '4'.repeat(64), code_verifier: 'verifier-123' }), + }); + expect(warnSpy).toHaveBeenCalledWith( + '[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', + 'not a url', + ); }); it('exchanges the callback code with the PKCE verifier stored in the admin session', async () => { diff --git a/src/server/auth.ts b/src/server/auth.ts index 5edfcd96..d27463e6 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -21,6 +21,18 @@ function extractCookieValue(response: Response, name: string): string | undefine return undefined; } +/** Returns the origin of ADMIN_PANEL_PUBLIC_URL when configured, giving deployments behind proxies that strip Origin and forward no proto/host metadata a deterministic override. */ +function getConfiguredPublicOrigin(): string | undefined { + const publicUrl = process.env.ADMIN_PANEL_PUBLIC_URL; + if (!publicUrl) return undefined; + try { + return new URL(publicUrl).origin; + } catch { + console.warn('[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', publicUrl); + return undefined; + } +} + /** Returns the referer's origin only when its host matches the serving host, so a foreign referer (e.g. an IdP such as Azure EntraID's login.microsoftonline.com) can never be forwarded as the panel's own origin. */ function getSameHostRefererOrigin(host: string): string | undefined { const referer = getRequestHeader('referer'); @@ -37,15 +49,19 @@ function getSameHostRefererOrigin(host: string): string | undefined { * Resolves the admin panel's browser-visible origin for LibreChat's exchange-code * origin binding. * - * Order: the `origin` header, then the first `x-forwarded-host` value (Host-rewriting - * proxies replace `host` with the internal upstream authority) combined with the first - * `x-forwarded-proto` value. When no forwarded proto is available, a same-host referer - * recovers the scheme for HTTPS deployments whose proxy strips `Origin` without setting - * `x-forwarded-proto`. A foreign referer is never used: it identifies whatever page or - * IdP initiated the request, and forwarding it makes LibreChat reject the exchange code - * as expired even though authentication succeeded. + * Order: the configured `ADMIN_PANEL_PUBLIC_URL` origin, then the `origin` header, + * then the first `x-forwarded-host` value (Host-rewriting proxies replace `host` with + * the internal upstream authority) combined with the first `x-forwarded-proto` value. + * When no forwarded proto is available, a same-host referer recovers the scheme for + * HTTPS deployments whose proxy strips `Origin` without setting `x-forwarded-proto`. + * A foreign referer is never used: it identifies whatever page or IdP initiated the + * request, and forwarding it makes LibreChat reject the exchange code as expired even + * though authentication succeeded. */ function getRequestOrigin(): string | undefined { + const configuredOrigin = getConfiguredPublicOrigin(); + if (configuredOrigin) return configuredOrigin; + const origin = getRequestHeader('origin'); if (origin) return origin; From 9cd1714b153a2d1c2a1c8907c2ce0d87a77ff945 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:33:49 -0700 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A7=AF=20fix:=20Harden=20Public=20URL?= =?UTF-8?q?=20Validation=20and=20Default-Port=20Referer=20Matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A syntactically valid non-web ADMIN_PANEL_PUBLIC_URL such as file: or mailto: parses to origin null or an unusable scheme and would override valid request headers, so the override now requires an http or https protocol and a non-null origin before it wins. A serving authority carrying an explicit default port like example.com:443 failed the same-host referer comparison because URL strips default ports, so the comparison now strips the referer scheme's default port from the serving host first. --- src/server/auth.oauth.test.ts | 50 +++++++++++++++++++++++++++++++++++ src/server/auth.ts | 25 +++++++++++++----- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 014402cb..206cbcac 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -241,6 +241,33 @@ describe('oauthExchangeFn', () => { }); }); + it('rejects a non-web ADMIN_PANEL_PUBLIC_URL scheme instead of sending a null origin', async () => { + process.env.ADMIN_PANEL_PUBLIC_URL = 'file:///etc/passwd'; + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('origin', 'https://example.com'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: '8'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: '8'.repeat(64), code_verifier: 'verifier-123' }), + }); + expect(warnSpy).toHaveBeenCalledWith( + '[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', + 'file:///etc/passwd', + ); + }); + it('falls back to header derivation when ADMIN_PANEL_PUBLIC_URL is malformed', async () => { process.env.ADMIN_PANEL_PUBLIC_URL = 'not a url'; sessionState.data = { codeVerifier: 'verifier-123' }; @@ -427,6 +454,29 @@ describe('oauthExchangeFn', () => { }); }); + it('matches a same-host referer when the serving authority carries an explicit default port', async () => { + sessionState.data = { codeVerifier: 'verifier-123' }; + requestHeaders.set('host', 'example.com:443'); + requestHeaders.set('referer', 'https://example.com/admin/auth/openid/callback?code=abc'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + await oauthExchangeFn({ data: { code: '7'.repeat(64) } }); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://example.com', + }, + body: JSON.stringify({ code: '7'.repeat(64), code_verifier: 'verifier-123' }), + }); + }); + it('never adopts a foreign referer origin even when x-forwarded-proto is absent', async () => { sessionState.data = { codeVerifier: 'verifier-123' }; requestHeaders.set('host', 'example.com'); diff --git a/src/server/auth.ts b/src/server/auth.ts index d27463e6..16b68e5b 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -26,20 +26,32 @@ function getConfiguredPublicOrigin(): string | undefined { const publicUrl = process.env.ADMIN_PANEL_PUBLIC_URL; if (!publicUrl) return undefined; try { - return new URL(publicUrl).origin; + const url = new URL(publicUrl); + if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== 'null') { + return url.origin; + } } catch { - console.warn('[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', publicUrl); - return undefined; + /* fall through to the warning */ } + console.warn('[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', publicUrl); + return undefined; } -/** Returns the referer's origin only when its host matches the serving host, so a foreign referer (e.g. an IdP such as Azure EntraID's login.microsoftonline.com) can never be forwarded as the panel's own origin. */ +const DEFAULT_SCHEME_PORTS: Record = { 'https:': ':443', 'http:': ':80' }; + +/** Returns the referer's origin only when its host matches the serving host, so a foreign referer (e.g. an IdP such as Azure EntraID's login.microsoftonline.com) can never be forwarded as the panel's own origin. `URL` strips the scheme's default port from the referer host, so an explicit default port on the serving authority (`example.com:443`) is stripped before comparing. */ function getSameHostRefererOrigin(host: string): string | undefined { const referer = getRequestHeader('referer'); if (!referer) return undefined; try { const url = new URL(referer); - return url.host.toLowerCase() === host.toLowerCase() ? url.origin : undefined; + const defaultPort = DEFAULT_SCHEME_PORTS[url.protocol] ?? ''; + const servingHost = host.toLowerCase(); + const normalizedHost = + defaultPort && servingHost.endsWith(defaultPort) + ? servingHost.slice(0, -defaultPort.length) + : servingHost; + return url.host.toLowerCase() === normalizedHost ? url.origin : undefined; } catch { return undefined; } @@ -232,7 +244,8 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn return { valid: false, error: 'Session expired due to inactivity' }; } - const needsRevalidation = !lastVerified || now - lastVerified > sessionConfig.revalidationInterval; + const needsRevalidation = + !lastVerified || now - lastVerified > sessionConfig.revalidationInterval; if (needsRevalidation) { try {