mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
🎯 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
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:
parent
f76a5faa9e
commit
c9180d1ad6
3 changed files with 280 additions and 5 deletions
158
packages/data-provider/specs/request-interceptor-subdir.spec.ts
Normal file
158
packages/data-provider/specs/request-interceptor-subdir.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue