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 569f4803..206cbcac 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,93 @@ 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('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' }; + 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 () => { @@ -243,6 +332,174 @@ 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('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('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('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'); + 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 8e7172b2..16b68e5b 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -21,24 +21,71 @@ function extractCookieValue(response: Response, name: string): string | undefine return undefined; } -function getRequestOrigin(): string | undefined { - const origin = getRequestHeader('origin'); - if (origin) return origin; +/** 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 { + const url = new URL(publicUrl); + if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== 'null') { + return url.origin; + } + } catch { + /* fall through to the warning */ + } + console.warn('[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', publicUrl); + return undefined; +} +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) { - try { - return new URL(referer).origin; - } catch { - return undefined; - } + if (!referer) return undefined; + try { + const url = new URL(referer); + 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; } +} + +/** + * Resolves the admin panel's browser-visible origin for LibreChat's exchange-code + * origin binding. + * + * 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 host = getRequestHeader('host'); + const origin = getRequestHeader('origin'); + if (origin) return origin; + + const forwardedHost = getRequestHeader('x-forwarded-host'); + const host = forwardedHost?.split(',')[0]?.trim() || getRequestHeader('host'); if (!host) return undefined; - const proto = getRequestHeader('x-forwarded-proto') ?? 'http'; - return `${proto}://${host}`; + const forwardedProto = getRequestHeader('x-forwarded-proto'); + const proto = forwardedProto?.split(',')[0]?.trim(); + if (proto) return `${proto}://${host}`; + + return getSameHostRefererOrigin(host) ?? `http://${host}`; } export const adminLoginFn = createServerFn({ method: 'POST' }) @@ -197,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 {