🎯 fix: Narrow Public Share 401 Bypass to the Share Endpoint Only (#12905)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

This commit is contained in:
Danny Avila 2026-06-20 13:57:21 -04:00 committed by GitHub
parent f76a5faa9e
commit c9180d1ad6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 280 additions and 5 deletions

View file

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

View file

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

View file

@ -81,6 +81,46 @@ type AuthRecoveryWindow = Window & {
const refreshToken = (retry?: boolean): Promise<t.TRefreshTokenResponse | undefined> =>
_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);
}