diff --git a/.env.example b/.env.example index 677a00c2c7..65673c2fcd 100644 --- a/.env.example +++ b/.env.example @@ -152,11 +152,6 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # RUM_AUTH_MODE=publicToken # RUM_PUBLIC_TOKEN= -# Authenticated mode sends the active LibreChat user JWT only to the configured RUM URL. -# Use only with a trusted HTTPS ingest URL that will not log or expose authorization headers. -# RUM_AUTH_MODE=userJwt -# RUM_AUTH_HEADER_SCHEME=Bearer - # Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers. # Wildcards and non-HTTPS targets are ignored. # RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com diff --git a/api/server/routes/__tests__/config.rum.spec.js b/api/server/routes/__tests__/config.rum.spec.js index f10685a6e7..867e0a0049 100644 --- a/api/server/routes/__tests__/config.rum.spec.js +++ b/api/server/routes/__tests__/config.rum.spec.js @@ -60,7 +60,6 @@ afterEach(() => { delete process.env.RUM_URL; delete process.env.RUM_SERVICE_NAME; delete process.env.RUM_AUTH_MODE; - delete process.env.RUM_AUTH_HEADER_SCHEME; delete process.env.RUM_PUBLIC_TOKEN; delete process.env.RUM_TRACE_PROPAGATION_TARGETS; delete process.env.RUM_CONSOLE_CAPTURE; @@ -136,30 +135,19 @@ describe('GET /api/config RUM config', () => { expect(response.body.rum?.url).toBe('http://[::1]:4318'); }); - it('includes userJwt RUM config for authenticated users', async () => { + it('omits unsupported userJwt RUM config for authenticated users', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); process.env.RUM_ENABLED = 'true'; process.env.RUM_URL = 'https://rum.example.com'; process.env.RUM_AUTH_MODE = 'userJwt'; - process.env.RUM_AUTH_HEADER_SCHEME = 'Basic'; const app = createApp(mockUser); const response = await request(app).get('/api/config'); - expect(response.body.rum).toEqual({ - provider: 'hyperdx', - enabled: true, - url: 'https://rum.example.com', - serviceName: 'librechat-web', - authMode: 'userJwt', - authHeaderScheme: 'Basic', - consoleCapture: false, - disableReplay: true, - advancedNetworkCapture: false, - }); + expect(response.body).not.toHaveProperty('rum'); }); - it('omits userJwt RUM config for unauthenticated users', async () => { + it('omits unsupported userJwt RUM config for unauthenticated users', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); process.env.RUM_ENABLED = 'true'; process.env.RUM_URL = 'https://rum.example.com'; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 8141bae97a..7038ac2f71 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -170,7 +170,7 @@ router.get('/', async function (req, res) { try { const sharedPayload = buildSharedPayload(); const cloudFront = buildCloudFrontStartupConfig(); - const rum = getRumConfig(req.user); + const rum = getRumConfig(); if (!req.user) { const tenantId = getTenantId(); diff --git a/api/server/services/Config/rum.js b/api/server/services/Config/rum.js index 7dcb176b72..da7bf5e555 100644 --- a/api/server/services/Config/rum.js +++ b/api/server/services/Config/rum.js @@ -2,7 +2,6 @@ const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const DEFAULT_RUM_SERVICE_NAME = 'librechat-web'; -let hasWarnedUserJwtAuth = false; function parseBooleanEnv(value, defaultValue = false) { if (value == null || value === '') { @@ -44,7 +43,7 @@ function isLocalhost(url) { return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; } -function isSafeRumUrl(url, authMode) { +function isSafeRumUrl(url) { if (url.username || url.password) { return false; } @@ -53,7 +52,7 @@ function isSafeRumUrl(url, authMode) { return true; } - return authMode === 'publicToken' && url.protocol === 'http:' && isLocalhost(url); + return url.protocol === 'http:' && isLocalhost(url); } function isSafeTraceTarget(target) { @@ -69,18 +68,7 @@ function isSafeTraceTarget(target) { return true; } -function warnOnceForUserJwtAuth() { - if (hasWarnedUserJwtAuth) { - return; - } - - hasWarnedUserJwtAuth = true; - logger.warn( - '[config] RUM userJwt mode sends the active LibreChat user JWT to RUM_URL; use only with a trusted HTTPS collector that will not log authorization headers', - ); -} - -function getRumConfig(user) { +function getRumConfig() { if (!parseBooleanEnv(process.env.RUM_ENABLED)) { return undefined; } @@ -91,24 +79,21 @@ function getRumConfig(user) { return undefined; } - const authMode = process.env.RUM_AUTH_MODE === 'userJwt' ? 'userJwt' : 'publicToken'; + const authMode = process.env.RUM_AUTH_MODE || 'publicToken'; + if (authMode !== 'publicToken') { + logger.warn(`[config] Unsupported RUM auth mode "${authMode}", disabling RUM`); + return undefined; + } + const rumUrl = process.env.RUM_URL; const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined; - if (!parsedUrl || !isSafeRumUrl(parsedUrl, authMode)) { + if (!parsedUrl || !isSafeRumUrl(parsedUrl)) { logger.warn('[config] Invalid RUM_URL, disabling RUM'); return undefined; } - if (authMode === 'userJwt' && !user) { - return undefined; - } - - if (authMode === 'userJwt') { - warnOnceForUserJwtAuth(); - } - - if (authMode === 'publicToken' && !process.env.RUM_PUBLIC_TOKEN) { + if (!process.env.RUM_PUBLIC_TOKEN) { logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM'); return undefined; } @@ -124,7 +109,6 @@ function getRumConfig(user) { configuredSampleRate != null && configuredSampleRate >= 0 && configuredSampleRate <= 1 ? configuredSampleRate : undefined; - const authHeaderScheme = process.env.RUM_AUTH_HEADER_SCHEME === 'Basic' ? 'Basic' : 'Bearer'; const consoleCapture = parseBooleanEnv(process.env.RUM_CONSOLE_CAPTURE); const advancedNetworkCapture = parseBooleanEnv(process.env.RUM_ADVANCED_NETWORK_CAPTURE); @@ -142,8 +126,7 @@ function getRumConfig(user) { url: parsedUrl.href.replace(/\/$/, ''), serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME, authMode, - ...(authMode === 'userJwt' ? { authHeaderScheme } : {}), - ...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}), + publicToken: process.env.RUM_PUBLIC_TOKEN, ...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}), consoleCapture, disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true), diff --git a/client/src/lib/rum/early.spec.ts b/client/src/lib/rum/early.spec.ts deleted file mode 100644 index 52f65b9b5d..0000000000 --- a/client/src/lib/rum/early.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -describe('RUM early interceptor', () => { - const originalFetch = window.fetch; - const OriginalXMLHttpRequest = window.XMLHttpRequest; - - beforeEach(() => { - jest.resetModules(); - window.fetch = jest.fn(() => Promise.resolve({} as Response)) as unknown as typeof fetch; - window.XMLHttpRequest = OriginalXMLHttpRequest; - }); - - afterEach(() => { - window.fetch = originalFetch; - window.XMLHttpRequest = OriginalXMLHttpRequest; - window.__libreChatRumInterceptor?.clear(); - delete window.__libreChatRumInterceptor; - }); - - it('adds auth only to the configured RUM origin and path', async () => { - await import('./early'); - - window.__libreChatRumInterceptor?.configure({ - url: 'https://rum.example.com/ingest', - authHeaderScheme: 'Bearer', - tokenProvider: () => 'token-123', - }); - - await fetch('https://rum.example.com/ingest/v1/traces'); - await fetch('https://rum.example.com.attacker.com/ingest/v1/traces'); - await fetch('https://rum.example.com/other'); - - const calls = (window.fetch as jest.MockedFunction).mock.calls; - expect(new Headers(calls[0][1]?.headers).get('authorization')).toBe('Bearer token-123'); - expect(calls[1][1]?.headers).toBeUndefined(); - expect(calls[2][1]?.headers).toBeUndefined(); - }); - - it('supports Basic auth when configured', async () => { - await import('./early'); - - window.__libreChatRumInterceptor?.configure({ - url: 'https://rum.example.com', - authHeaderScheme: 'Basic', - tokenProvider: () => 'token-123', - }); - - await fetch('https://rum.example.com/v1/traces'); - - const calls = (window.fetch as jest.MockedFunction).mock.calls; - expect(new Headers(calls[0][1]?.headers).get('authorization')).toBe('Basic token-123'); - }); - - it('leaves requests unchanged when token is unavailable', async () => { - await import('./early'); - - window.__libreChatRumInterceptor?.configure({ - url: 'https://rum.example.com', - tokenProvider: () => undefined, - }); - - await fetch('https://rum.example.com/v1/traces'); - - const calls = (window.fetch as jest.MockedFunction).mock.calls; - expect(calls[0][1]?.headers).toBeUndefined(); - }); - - it('preserves XMLHttpRequest constructor constants', async () => { - await import('./early'); - - expect(window.XMLHttpRequest.DONE).toBe(OriginalXMLHttpRequest.DONE); - expect(window.XMLHttpRequest.prototype).toBe(OriginalXMLHttpRequest.prototype); - }); - - it('falls back to SDK-set XHR auth when the token provider is empty', async () => { - const instances: Array<{ headers: Map }> = []; - - class FakeXMLHttpRequest { - static DONE = 4; - headers = new Map(); - - constructor() { - instances.push(this); - } - - open() { - return undefined; - } - - setRequestHeader(name: string, value: string) { - this.headers.set(name.toLowerCase(), value); - } - - send() { - return undefined; - } - } - - window.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; - await import('./early'); - - window.__libreChatRumInterceptor?.configure({ - url: 'https://rum.example.com/ingest', - tokenProvider: () => undefined, - }); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', 'https://rum.example.com/ingest/v1/traces'); - xhr.setRequestHeader('authorization', 'Bearer sdk-key'); - xhr.send(); - - expect(instances[0].headers.get('authorization')).toBe('Bearer sdk-key'); - }); - - it('preserves XHR credentials when async is omitted', async () => { - const openCalls: unknown[][] = []; - - class FakeXMLHttpRequest { - static DONE = 4; - - open(...args: unknown[]) { - openCalls.push(args); - } - - setRequestHeader() { - return undefined; - } - - send() { - return undefined; - } - } - - window.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; - await import('./early'); - - const xhr = new XMLHttpRequest(); - (xhr.open as unknown as (...args: unknown[]) => void)( - 'GET', - 'https://api.example.com/resource', - undefined, - 'user', - 'password', - ); - - expect(openCalls[0]).toEqual([ - 'GET', - 'https://api.example.com/resource', - true, - 'user', - 'password', - ]); - }); -}); diff --git a/client/src/lib/rum/early.ts b/client/src/lib/rum/early.ts deleted file mode 100644 index ea7bffe20a..0000000000 --- a/client/src/lib/rum/early.ts +++ /dev/null @@ -1,163 +0,0 @@ -type TokenProvider = () => string | undefined; - -type RumInterceptorConfig = { - url?: string; - tokenProvider?: TokenProvider; - authHeaderScheme?: 'Bearer' | 'Basic'; -}; - -declare global { - interface Window { - __libreChatRumInterceptor?: { - configure: (config: RumInterceptorConfig) => void; - clear: () => void; - }; - } -} - -let rumUrl: URL | undefined; -let tokenProvider: TokenProvider | undefined; -let authHeaderScheme: 'Bearer' | 'Basic' = 'Bearer'; - -const originalFetch = window.fetch.bind(window); -const OriginalXMLHttpRequest = window.XMLHttpRequest; - -function isRequest(input: RequestInfo | URL): input is Request { - return typeof Request !== 'undefined' && input instanceof Request; -} - -function parseUrl(value: string | URL): URL | undefined { - try { - return value instanceof URL ? value : new URL(value, window.location.origin); - } catch { - return undefined; - } -} - -function matchesRumUrl(value: string | URL): boolean { - if (!rumUrl) { - return false; - } - - const requestUrl = parseUrl(value); - if (!requestUrl || requestUrl.origin !== rumUrl.origin) { - return false; - } - - const basePath = rumUrl.pathname.endsWith('/') ? rumUrl.pathname : `${rumUrl.pathname}/`; - return requestUrl.pathname === rumUrl.pathname || requestUrl.pathname.startsWith(basePath); -} - -function getAuthorization(): string | undefined { - const token = tokenProvider?.(); - return token ? `${authHeaderScheme} ${token}` : undefined; -} - -const interceptedFetch = function interceptedFetch( - input: RequestInfo | URL, - init?: RequestInit, -): Promise { - const requestUrl = isRequest(input) ? input.url : input; - - if (!matchesRumUrl(requestUrl)) { - return originalFetch(input, init); - } - - const authorization = getAuthorization(); - if (!authorization) { - return originalFetch(input, init); - } - - const headers = new Headers(isRequest(input) ? input.headers : init?.headers); - headers.set('authorization', authorization); - - return originalFetch(input, { - ...init, - headers, - }); -}; - -window.fetch = Object.assign(interceptedFetch, window.fetch); - -function InterceptedXMLHttpRequest(): XMLHttpRequest { - const xhr = new OriginalXMLHttpRequest(); - const originalOpen = xhr.open; - const originalSend = xhr.send; - const originalSetRequestHeader = xhr.setRequestHeader; - let isRumRequest = false; - let rumSdkAuthorization: string | undefined; - - xhr.open = function open( - method: string, - url: string | URL, - async?: boolean, - username?: string | null, - password?: string | null, - ): void { - isRumRequest = matchesRumUrl(url); - - if (arguments.length >= 5) { - originalOpen.call( - this, - method, - url, - async ?? true, - username ?? undefined, - password ?? undefined, - ); - return; - } - - if (arguments.length === 4) { - originalOpen.call(this, method, url, async ?? true, username ?? undefined); - return; - } - - if (arguments.length === 3) { - originalOpen.call(this, method, url, async ?? true); - return; - } - - originalOpen.call(this, method, url); - }; - - xhr.setRequestHeader = function setRequestHeader(name: string, value: string): void { - if (isRumRequest && name.toLowerCase() === 'authorization') { - // HyperDX sets its own API key header; userJwt mode replaces it with the LibreChat auth token. - rumSdkAuthorization = value; - return; - } - - originalSetRequestHeader.call(this, name, value); - }; - - xhr.send = function send(body?: Document | XMLHttpRequestBodyInit | null): void { - const authorization = isRumRequest ? (getAuthorization() ?? rumSdkAuthorization) : undefined; - if (authorization) { - originalSetRequestHeader.call(this, 'authorization', authorization); - } - - originalSend.call(this, body); - }; - - return xhr; -} - -Object.setPrototypeOf(InterceptedXMLHttpRequest, OriginalXMLHttpRequest); -InterceptedXMLHttpRequest.prototype = OriginalXMLHttpRequest.prototype; -window.XMLHttpRequest = InterceptedXMLHttpRequest as unknown as typeof XMLHttpRequest; - -window.__libreChatRumInterceptor = { - configure(config: RumInterceptorConfig) { - rumUrl = config.url ? parseUrl(config.url) : undefined; - tokenProvider = config.tokenProvider; - authHeaderScheme = config.authHeaderScheme ?? 'Bearer'; - }, - clear() { - rumUrl = undefined; - tokenProvider = undefined; - authHeaderScheme = 'Bearer'; - }, -}; - -export {}; diff --git a/client/src/lib/rum/useRum.spec.tsx b/client/src/lib/rum/useRum.spec.tsx index 74543a7fd5..f76362dcaf 100644 --- a/client/src/lib/rum/useRum.spec.tsx +++ b/client/src/lib/rum/useRum.spec.tsx @@ -30,6 +30,7 @@ jest.mock('react-router-dom', () => ({ describe('useRum', () => { beforeEach(() => { + jest.clearAllMocks(); mockUseLocation.mockReturnValue({ pathname: '/c/conversation-123' }); mockUseAuthContext.mockReturnValue({ isAuthenticated: true, @@ -41,7 +42,6 @@ describe('useRum', () => { email: 'user@example.com', }, }); - delete window.__libreChatRumInterceptor; }); it('initializes HyperDX public-token RUM with privacy defaults and safe attributes', async () => { @@ -85,10 +85,7 @@ describe('useRum', () => { ); }); - it('configures the early interceptor and uses a placeholder api key for userJwt mode', async () => { - const configure = jest.fn(); - const clear = jest.fn(); - window.__libreChatRumInterceptor = { configure, clear }; + it('does not initialize RUM for unsupported auth modes', async () => { mockUseGetStartupConfig.mockReturnValue({ data: { rum: { @@ -97,27 +94,13 @@ describe('useRum', () => { url: 'https://rum.example.com/ingest', serviceName: 'librechat-web', authMode: 'userJwt', - authHeaderScheme: 'Basic', + publicToken: 'public-token', }, }, }); renderHook(() => useRum()); - expect(configure).toHaveBeenCalledWith({ - url: 'https://rum.example.com/ingest', - authHeaderScheme: 'Basic', - tokenProvider: expect.any(Function), - }); - expect(configure.mock.calls[0][0].tokenProvider()).toBe('jwt-token'); - - await waitFor(() => { - expect(mockInit).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: 'placeholder', - url: 'https://rum.example.com/ingest', - }), - ); - }); + expect(mockInit).not.toHaveBeenCalled(); }); }); diff --git a/client/src/lib/rum/useRum.ts b/client/src/lib/rum/useRum.ts index d7cefd7989..76757ab602 100644 --- a/client/src/lib/rum/useRum.ts +++ b/client/src/lib/rum/useRum.ts @@ -18,20 +18,16 @@ type HyperDXBrowser = { setGlobalAttributes: (attributes: Record) => void; }; -function shouldInitializeRum(config: TRumConfig | undefined, token: string | undefined): boolean { +function shouldInitializeRum(config: TRumConfig | undefined): boolean { if (!config?.enabled || config.provider !== 'hyperdx' || !config.url || !config.serviceName) { return false; } - if (config.authMode === 'publicToken') { - return !!config.publicToken; - } - - return !!token; + return config.authMode === 'publicToken' && !!config.publicToken; } function getApiKey(config: TRumConfig): string { - return config.authMode === 'publicToken' ? (config.publicToken ?? '') : 'placeholder'; + return config.publicToken ?? ''; } function buildGlobalAttributes( @@ -60,51 +56,26 @@ async function loadHyperDX(): Promise { export default function useRum(): void { const { data: startupConfig } = useGetStartupConfig(); - const { isAuthenticated, token, user } = useAuthContext(); + const { user } = useAuthContext(); const location = useLocation(); const initializedKeyRef = useRef(undefined); const sampledInitKeyRef = useRef(undefined); const sampledInRef = useRef(true); const hyperDxRef = useRef(undefined); - const tokenRef = useRef(token); const rumConfig = startupConfig?.rum; const route = useMemo(() => normalizeRumPath(location.pathname), [location.pathname]); const routeRef = useRef(route); - useEffect(() => { - tokenRef.current = token; - }, [token]); - useEffect(() => { routeRef.current = route; }, [route]); - useEffect(() => { - if (!rumConfig || rumConfig.authMode !== 'userJwt') { - window.__libreChatRumInterceptor?.clear(); - return; - } - - window.__libreChatRumInterceptor?.configure({ - url: rumConfig.url, - authHeaderScheme: rumConfig.authHeaderScheme, - tokenProvider: () => tokenRef.current, - }); - - return () => { - window.__libreChatRumInterceptor?.clear(); - }; - }, [rumConfig]); - useEffect(() => { if (!rumConfig) { return; } - if ( - !shouldInitializeRum(rumConfig, token) || - (rumConfig.authMode === 'userJwt' && !isAuthenticated) - ) { + if (!shouldInitializeRum(rumConfig)) { return; } @@ -153,7 +124,7 @@ export default function useRum(): void { return () => { cancelled = true; }; - }, [isAuthenticated, rumConfig, token, user]); + }, [rumConfig, user]); useEffect(() => { hyperDxRef.current?.setGlobalAttributes( diff --git a/client/src/main.jsx b/client/src/main.jsx index ad2f105efe..53b483f8d2 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,4 +1,3 @@ -import './lib/rum/early'; import 'regenerator-runtime/runtime'; import { createRoot } from 'react-dom/client'; import './locales/i18n'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 487b13e840..7e8f8b259f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1071,8 +1071,7 @@ export type TRumConfig = { enabled: boolean; url: string; serviceName: string; - authMode: 'publicToken' | 'userJwt'; - authHeaderScheme?: 'Bearer' | 'Basic'; + authMode: 'publicToken'; publicToken?: string; tracePropagationTargets?: string[]; consoleCapture?: boolean; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index a6a6be1433..35774dd664 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -213,6 +213,7 @@ export type TUser = { avatar: string; role: string; provider: string; + tenantId?: string; plugins?: string[]; twoFactorEnabled?: boolean; backupCodes?: TBackupCode[];