diff --git a/packages/data-provider/specs/request-interceptor-subdir.spec.ts b/packages/data-provider/specs/request-interceptor-subdir.spec.ts new file mode 100644 index 0000000000..82d83a677a --- /dev/null +++ b/packages/data-provider/specs/request-interceptor-subdir.spec.ts @@ -0,0 +1,158 @@ +/** + * @jest-environment @happy-dom/jest-environment + */ +import axios from 'axios'; +import { setTokenHeader } from '../src/headers-helpers'; + +const mockAdapter = jest.fn(); +let originalAdapter: typeof axios.defaults.adapter; +let savedLocation: Location; +let baseElement: HTMLBaseElement; +let originalProcessBrowser: boolean | undefined; + +beforeAll(async () => { + originalAdapter = axios.defaults.adapter; + axios.defaults.adapter = mockAdapter; + + baseElement = document.createElement('base'); + baseElement.setAttribute('href', '/chat/'); + document.head.appendChild(baseElement); + + const proc = process as typeof process & { browser?: boolean }; + originalProcessBrowser = proc.browser; + proc.browser = true; + + await import('../src/request'); +}); + +beforeEach(() => { + mockAdapter.mockReset(); + savedLocation = window.location; +}); + +afterAll(() => { + axios.defaults.adapter = originalAdapter; + document.head.removeChild(baseElement); + + const proc = process as typeof process & { browser?: boolean }; + if (originalProcessBrowser === undefined) { + delete proc.browser; + } else { + proc.browser = originalProcessBrowser; + } +}); + +afterEach(() => { + delete axios.defaults.headers.common['Authorization']; + window.localStorage.clear(); + delete (window as Window & { __librechatAuthRecovery?: unknown }).__librechatAuthRecovery; + Object.defineProperty(window, 'location', { + value: savedLocation, + writable: true, + configurable: true, + }); +}); + +function setWindowLocation(overrides: Partial) { + Object.defineProperty(window, 'location', { + value: { ...window.location, ...overrides }, + writable: true, + configurable: true, + }); +} + +describe('axios 401 interceptor — subdirectory shared link guard', () => { + it('recognizes base-prefixed shared link data requests', async () => { + expect.assertions(2); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/chat/share/abc123', + pathname: '/chat/share/abc123', + search: '', + hash: '', + origin: 'http://localhost', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/chat/api/share/abc123', method: 'get', headers: {} }, + }); + + mockAdapter.mockResolvedValueOnce({ + data: { token: 'new-token' }, + status: 200, + headers: {}, + config: {}, + }); + + mockAdapter.mockResolvedValueOnce({ + data: { sharedLink: {} }, + status: 200, + headers: {}, + config: {}, + }); + + try { + await axios.get('/chat/api/share/abc123'); + } catch { + // may reject depending on exact flow + } + + expect(mockAdapter.mock.calls.length).toBe(3); + + const refreshCall = mockAdapter.mock.calls[1]; + expect(refreshCall[0].url).toBe('/chat/api/auth/refresh'); + }); + + it('does not refresh or redirect for unrelated base-prefixed 401s on public shared links', async () => { + expect.assertions(2); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/chat/share/abc123', + pathname: '/chat/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/chat/api/mcp/servers', method: 'get', headers: {} }, + }); + + try { + await axios.get('/chat/api/mcp/servers'); + } catch { + // expected rejection + } + + expect(mockAdapter).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe('http://localhost/chat/share/abc123'); + }); + + it('does not strip paths that only share the base prefix', async () => { + expect.assertions(1); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/chatroom/share/abc123', + pathname: '/chatroom/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/chatroom/api/share/abc123', method: 'get', headers: {} }, + }); + + try { + await axios.get('/chatroom/api/share/abc123'); + } catch { + // expected rejection + } + + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/data-provider/specs/request-interceptor.spec.ts b/packages/data-provider/specs/request-interceptor.spec.ts index 8de8d541a1..fec6a45cec 100644 --- a/packages/data-provider/specs/request-interceptor.spec.ts +++ b/packages/data-provider/specs/request-interceptor.spec.ts @@ -158,7 +158,7 @@ describe('axios 401 interceptor — Authorization header guard', () => { mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, - config: { url: '/api/share/abc123', headers: {} }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ @@ -187,6 +187,82 @@ describe('axios 401 interceptor — Authorization header guard', () => { expect(refreshCall[0].url).toContain('api/auth/refresh'); }); + it('does not refresh or redirect for unrelated 401s on public shared link pages', async () => { + expect.assertions(2); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/share/abc123', + pathname: '/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/mcp/servers', headers: {} }, + }); + + try { + await axios.get('/api/mcp/servers'); + } catch { + // expected rejection + } + + expect(mockAdapter).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe('http://localhost/share/abc123'); + }); + + it('does not treat nested share routes as public shared link pages', async () => { + expect.assertions(1); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/foo/share/abc123', + pathname: '/foo/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, + }); + + try { + await axios.get('/api/share/abc123'); + } catch { + // expected rejection + } + + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); + + it('does not treat nested API share paths as shared message requests', async () => { + expect.assertions(1); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/share/abc123', + pathname: '/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/foo/api/share/abc123', method: 'get', headers: {} }, + }); + + try { + await axios.get('/foo/api/share/abc123'); + } catch { + // expected rejection + } + + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); + it('does not bypass guard when share/ appears only in query params', async () => { expect.assertions(1); setTokenHeader(undefined); @@ -225,7 +301,7 @@ describe('axios 401 interceptor — Authorization header guard', () => { mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, - config: { url: '/api/share/abc123', headers: {} }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ @@ -257,7 +333,7 @@ describe('axios 401 interceptor — Authorization header guard', () => { mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, - config: { url: '/api/share/abc123', headers: {} }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index d6a1a85dea..ca2a98eedf 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -81,6 +81,46 @@ type AuthRecoveryWindow = Window & { const refreshToken = (retry?: boolean): Promise => _post(endpoints.refreshToken(retry)); +const SHARE_PAGE_PATH_REGEX = /^\/share\/[^/]+\/?$/; +const SHARED_MESSAGES_PATH_REGEX = /^\/api\/share\/[^/]+$/; + +const normalizePathname = (pathname: string) => + pathname.startsWith('/') ? pathname : `/${pathname}`; + +const stripBasePath = (pathname: string) => { + const normalizedPathname = normalizePathname(pathname); + const baseUrl = endpoints.apiBaseUrl(); + if (!baseUrl) { + return normalizedPathname; + } + + const normalizedBaseUrl = normalizePathname(baseUrl); + if ( + normalizedPathname === normalizedBaseUrl || + normalizedPathname.startsWith(`${normalizedBaseUrl}/`) + ) { + return normalizedPathname.slice(normalizedBaseUrl.length) || '/'; + } + return normalizedPathname; +}; + +const isSharePage = () => SHARE_PAGE_PATH_REGEX.test(stripBasePath(window.location.pathname)); + +const getRequestPathname = (url?: string) => { + if (typeof url !== 'string') { + return ''; + } + try { + return new URL(url, window.location.origin).pathname; + } catch { + return url.split(/[?#]/)[0] ?? ''; + } +}; + +const isSharedMessagesRequest = (url?: string, method?: string) => + method?.toLowerCase() === 'get' && + SHARED_MESSAGES_PATH_REGEX.test(stripBasePath(getRequestPathname(url))); + const dispatchTokenUpdatedEvent = (token: string) => { setTokenHeader(token); clearAuthRedirectStartedAt(); @@ -274,10 +314,11 @@ if (typeof window !== 'undefined') { } /** Skip refresh when the Authorization header has been cleared (e.g. during logout), - * but allow shared link requests to proceed so auth recovery/redirect can happen */ + * but allow the shared link data request to proceed so private shares can still + * recover auth/redirect without unrelated share-page queries forcing login. */ if ( !axios.defaults.headers.common['Authorization'] && - !window.location.pathname.startsWith('/share/') + !(isSharePage() && isSharedMessagesRequest(originalRequest.url, originalRequest.method)) ) { return Promise.reject(error); }