mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🔒 chore: Upgrade react-router-dom to v7.18.2 (security) (#14582)
* 📦 chore: Upgrade react-router-dom to v7.18.2 (security) Fixes GHSA-wrjc-x8rr-h8h6 (open redirect via backslash in Link/useNavigate, CVE-2025-68470 bypass) and GHSA-337j-9hxr-rhxg (deserializeErrors constructor injection). Neither has a 6.x patch; v7's react-router-dom is a shim re-exporting react-router, so all existing imports work unchanged. - vite manualChunks: match react-router so the routing chunk still captures the router (v7 moves all code out of the react-router-dom package) - jest: add test/polyfills.js (TextEncoder/TextDecoder + minimal Request); v7's CJS bundle constructs TextEncoder at module scope and builds a Request per navigation, neither exists in jsdom - auth specs: v7 types drop the synthetic default export; use a namespace import and mark the mock factory __esModule so the useOutletContext spy patches the object components actually read - isSafeRedirect: reject backslashes as defense in depth for the same open-redirect class the router patch addresses * 📦 chore: Regenerate stale bun.lock bun.lock predated months of package.json drift and still pinned react-router 6.30.3. Regenerated with bun install --lockfile-only so bun installs match current manifests, including react-router 7.18.2. * 🗂️ fix: Commit project-chip URL updates synchronously under router v7 v7 wraps router state updates in React.startTransition unconditionally, so the chip's paired updates tear: the conversation draft (Recoil) commits synchronously while the ?projectId removal defers. ChatRoute's draftProjectMismatch re-init sees draft != URL in that window and restores the removed project. The flushSync navigate option commits both in one pass, matching v6 ordering. Caught by the projects e2e specs. * 🧹 chore: Drop unused banner-query spy variable in Registration spec Pre-existing warning, but the changed-files eslint gate runs with --max-warnings=0 so it blocks this PR. The spy call stays; only the never-read variable goes.
This commit is contained in:
parent
3551c1ba8e
commit
96499f0765
13 changed files with 2132 additions and 3598 deletions
|
|
@ -45,6 +45,7 @@ module.exports = {
|
|||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!(@ariakit/react-components|@ariakit/react-utils|@ariakit/react-store|@ariakit/components|@ariakit/store|@ariakit/utils|@zattoo/use-double-click|@dicebear|@react-dnd|react-dnd.*|dnd-core|filenamify|filename-reserved-regex|heic-to|lowlight|highlight\\.js|fault|react-markdown|unified|bail|trough|devlop|is-.*|parse-entities|stringify-entities|character-.*|trim-lines|style-to-object|inline-style-parser|html-url-attributes|escape-string-regexp|longest-streak|zwitch|ccount|markdown-table|comma-separated-tokens|space-separated-tokens|web-namespaces|property-information|remark-.*|rehype-.*|recma-.*|hast.*|mdast-.*|unist-.*|vfile.*|micromark.*|estree-util-.*|decode-named-character-reference)/)/',
|
||||
],
|
||||
setupFiles: ['<rootDir>/test/polyfills.js'],
|
||||
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
|
||||
clearMocks: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
"react-i18next": "^15.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^4.7.4",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import reactRouter from 'react-router-dom';
|
||||
import * as reactRouter from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import { getByTestId, render, waitFor } from 'test/layout-test-utils';
|
||||
|
|
@ -114,6 +114,7 @@ const setup = ({
|
|||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
__esModule: true,
|
||||
useOutletContext: () => ({
|
||||
startupConfig: mockStartupConfig,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import reactRouter from 'react-router-dom';
|
||||
import * as reactRouter from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, waitFor, screen } from 'test/layout-test-utils';
|
||||
import * as mockDataProvider from 'librechat-data-provider/react-query';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import * as miscDataProvider from '~/data-provider/Misc/queries';
|
||||
import * as endpointQueries from '~/data-provider/Endpoints/queries';
|
||||
import { render, waitFor, screen } from 'test/layout-test-utils';
|
||||
import * as miscDataProvider from '~/data-provider/Misc/queries';
|
||||
import * as authMutations from '~/data-provider/Auth/mutations';
|
||||
import * as authQueries from '~/data-provider/Auth/queries';
|
||||
import Registration from '~/components/Auth/Registration';
|
||||
|
|
@ -83,7 +83,7 @@ const setup = ({
|
|||
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
|
||||
startupConfig: useGetStartupConfigReturnValue.data,
|
||||
});
|
||||
const mockUseGetBannerQuery = jest
|
||||
jest
|
||||
.spyOn(miscDataProvider, 'useGetBannerQuery')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetBannerQueryReturnValue);
|
||||
|
|
@ -112,6 +112,7 @@ const setup = ({
|
|||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
__esModule: true,
|
||||
useOutletContext: () => ({
|
||||
startupConfig: mockStartupConfig,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Folder, X } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ControlCombobox, TooltipAnchor } from '@librechat/client';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
|
|
@ -45,7 +45,9 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
|
|||
} else {
|
||||
nextParams.delete('projectId');
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
/** flushSync commits the URL with the draft update in one pass; a deferred
|
||||
* (startTransition) URL lets ChatRoute see draft≠URL and re-init the scope */
|
||||
setSearchParams(nextParams, { replace: true, flushSync: true });
|
||||
},
|
||||
[conversation, setConversation, searchParams, setSearchParams],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,15 +5,6 @@ import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
|||
import StartupLayout from '~/routes/Layouts/Startup';
|
||||
import { SESSION_KEY } from '~/utils';
|
||||
|
||||
if (typeof Request === 'undefined') {
|
||||
global.Request = class Request {
|
||||
constructor(
|
||||
public url: string,
|
||||
public init?: RequestInit,
|
||||
) {}
|
||||
} as any;
|
||||
}
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: jest.fn(() => ({
|
||||
data: null,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,6 @@ import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
|||
import useAuthRedirect from '../useAuthRedirect';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
// Polyfill Request for React Router in test environment
|
||||
if (typeof Request === 'undefined') {
|
||||
global.Request = class Request {
|
||||
constructor(
|
||||
public url: string,
|
||||
public init?: RequestInit,
|
||||
) {}
|
||||
} as any;
|
||||
}
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useAuthContext: jest.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ describe('isSafeRedirect', () => {
|
|||
expect(isSafeRedirect('evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a backslash protocol-relative URL (CVE-2025-68470 class)', () => {
|
||||
expect(isSafeRedirect('/\\evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a double-backslash URL', () => {
|
||||
expect(isSafeRedirect('\\\\evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a backslash anywhere in the path', () => {
|
||||
expect(isSafeRedirect('/c/new\\x')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(isSafeRedirect('')).toBe(false);
|
||||
});
|
||||
|
|
@ -103,6 +115,17 @@ describe('getPostLoginRedirect', () => {
|
|||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an encoded backslash URL from params', () => {
|
||||
const params = new URLSearchParams('redirect_to=%2F%5Cevil.com');
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a backslash URL from sessionStorage', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, '/\\evil.com');
|
||||
const params = new URLSearchParams();
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an absolute URL from sessionStorage', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, 'https://evil.com');
|
||||
const params = new URLSearchParams();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/;
|
|||
|
||||
/** Validates that a redirect target is a safe relative path (not an absolute or protocol-relative URL) */
|
||||
export function isSafeRedirect(url: string): boolean {
|
||||
if (!url.startsWith('/') || url.startsWith('//')) {
|
||||
if (!url.startsWith('/') || url.startsWith('//') || url.includes('\\')) {
|
||||
return false;
|
||||
}
|
||||
const path = url.split('?')[0].split('#')[0];
|
||||
|
|
|
|||
20
client/test/polyfills.js
Normal file
20
client/test/polyfills.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const { TextEncoder, TextDecoder } = require('node:util');
|
||||
|
||||
if (typeof globalThis.TextEncoder === 'undefined') {
|
||||
globalThis.TextEncoder = TextEncoder;
|
||||
}
|
||||
if (typeof globalThis.TextDecoder === 'undefined') {
|
||||
globalThis.TextDecoder = TextDecoder;
|
||||
}
|
||||
|
||||
/** jsdom lacks fetch primitives; react-router builds a Request per navigation and reads its fields */
|
||||
if (typeof globalThis.Request === 'undefined') {
|
||||
globalThis.Request = class Request {
|
||||
constructor(url, init) {
|
||||
this.url = String(url);
|
||||
this.method = init?.method ?? 'GET';
|
||||
this.headers = init?.headers ?? {};
|
||||
this.signal = init?.signal;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -255,7 +255,7 @@ export default defineConfig(({ command }) => ({
|
|||
if (normalizedId.includes('react-hook-form')) {
|
||||
return 'forms';
|
||||
}
|
||||
if (normalizedId.includes('react-router-dom')) {
|
||||
if (normalizedId.includes('react-router')) {
|
||||
return 'routing';
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
64
package-lock.json
generated
64
package-lock.json
generated
|
|
@ -996,7 +996,7 @@
|
|||
"react-i18next": "^15.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^4.7.4",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
|
|
@ -16829,15 +16829,6 @@
|
|||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remote-dom/core": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@remote-dom/core/-/core-1.9.0.tgz",
|
||||
|
|
@ -36862,35 +36853,54 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
"react-router": "7.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router/node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/react-speech-recognition": {
|
||||
|
|
@ -38632,6 +38642,12 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue