✂️ chore: Strip Session JWT Forwarding from Browser RUM (#13414)

* fix: disable RUM user JWT auth

* fix: remove stale RUM bootstrap import
This commit is contained in:
Danny Avila 2026-05-30 10:34:44 -04:00 committed by GitHub
parent b61d5377ef
commit 444d923e29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 28 additions and 424 deletions

View file

@ -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<typeof fetch>).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<typeof fetch>).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<typeof fetch>).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<string, string> }> = [];
class FakeXMLHttpRequest {
static DONE = 4;
headers = new Map<string, string>();
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',
]);
});
});

View file

@ -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<Response> {
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 {};

View file

@ -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();
});
});

View file

@ -18,20 +18,16 @@ type HyperDXBrowser = {
setGlobalAttributes: (attributes: Record<string, string>) => 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<HyperDXBrowser> {
export default function useRum(): void {
const { data: startupConfig } = useGetStartupConfig();
const { isAuthenticated, token, user } = useAuthContext();
const { user } = useAuthContext();
const location = useLocation();
const initializedKeyRef = useRef<string | undefined>(undefined);
const sampledInitKeyRef = useRef<string | undefined>(undefined);
const sampledInRef = useRef<boolean>(true);
const hyperDxRef = useRef<HyperDXBrowser | undefined>(undefined);
const tokenRef = useRef<string | undefined>(token);
const rumConfig = startupConfig?.rum;
const route = useMemo(() => normalizeRumPath(location.pathname), [location.pathname]);
const routeRef = useRef<string>(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(

View file

@ -1,4 +1,3 @@
import './lib/rum/early';
import 'regenerator-runtime/runtime';
import { createRoot } from 'react-dom/client';
import './locales/i18n';