From 0e14d91ed9e8a7b6f78d1802bb1489380af66246 Mon Sep 17 00:00:00 2001 From: Dustin Healy Date: Thu, 6 Aug 2026 06:05:42 -0700 Subject: [PATCH] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20fix:=20Compile=20admin=20f?= =?UTF-8?q?ile-config=20MIME=20patterns=20on=20a=20linear-time=20engine=20?= =?UTF-8?q?(ReDoS)=20(#14555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⏱️ fix: Compile admin file-config MIME patterns on a linear-time engine convertStringsToRegex compiled admin-configured supportedMimeTypes with the native RegExp engine, and checkType runs those patterns against an uploaded file's Content-Type on the server event loop, so a catastrophic-backtracking pattern in fileConfig could ReDoS the whole process on upload. The MIME-pattern compiler is now swappable. It defaults to native RegExp, which browser builds keep so no engine is added to the client bundle, and the server injects a linear-time engine (RE2JS) at startup. Only test is ever called on these matchers, so the shared type widens to a structural RegexLike with no behavior change for valid patterns. The browser stays on native because a client-side stall would only affect that one tab. * ⏱️ fix: Wire the linear MIME compiler in the experimental entry point api/server/experimental.js mounts the same upload routes and calls mergeFileConfig but never set the linear-time compiler, so admin MIME patterns still compiled with native RegExp there. Mirror the setup, and widen the client-side supportedMimeTypes type to the shared RegexLike so the browser typechecks against the same structural matcher. * 🧹 refactor: Configure the file-config linear engine from a shared helper Move the RE2 wiring out of both JS server entry points into a single configureFileConfigRegexEngine helper exported from @librechat/api, so /api stays a thin caller and the setup no longer has to be kept in sync across index.js and experimental.js. Also warn loudly when compiling an endpoint's supportedMimeTypes drops every pattern (an empty allowlist would reject all uploads), and correct the isMimeTypeSupported docstring to say RegexLike rather than RegExp. * fix: fail closed when every MIME pattern fails to compile convertStringsToRegex returned [] when all configured patterns failed to compile, and filter.ts reads an empty allowlist as no restriction, so a restrictive config whose patterns all fail allowed every attachment. Return a single reject-all matcher instead so every consumer fails closed. --- api/server/experimental.js | 4 ++ api/server/index.js | 6 ++- client/src/utils/files.ts | 4 +- .../api/src/files/fileConfigRegex.spec.ts | 19 ++++++++ packages/api/src/files/filter.ts | 5 +- packages/api/src/files/index.ts | 1 + packages/api/src/files/regexEngine.ts | 12 +++++ .../data-provider/src/file-config.spec.ts | 34 +++++++++++++ packages/data-provider/src/file-config.ts | 48 ++++++++++++++----- packages/data-provider/src/types/files.ts | 15 +++--- 10 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 packages/api/src/files/fileConfigRegex.spec.ts create mode 100644 packages/api/src/files/regexEngine.ts diff --git a/api/server/experimental.js b/api/server/experimental.js index d99b9ca12b..9bc60f627b 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -26,6 +26,7 @@ const { requestContextMiddleware, configureServerTimeouts, configureMessageFilterRegexValidator, + configureFileConfigRegexEngine, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); @@ -51,6 +52,9 @@ const optionalJwtAuth = require('./middleware/optionalJwtAuth'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); +/** Route admin file-config MIME patterns through a linear-time engine (ReDoS-safe) on upload. */ +configureFileConfigRegexEngine(); + /** Reject messageFilter PII patterns the RE2 runtime engine cannot compile, at config load. */ configureMessageFilterRegexValidator(); diff --git a/api/server/index.js b/api/server/index.js index 7d9b5d9293..f578fb61fb 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -34,6 +34,7 @@ const { setupGracefulShutdown, updateInterfacePermissions, configureMessageFilterRegexValidator, + configureFileConfigRegexEngine, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const { @@ -45,9 +46,9 @@ const { const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const { capabilityContextMiddleware } = require('./middleware/roles/capabilities'); const createValidateImageRequest = require('./middleware/validateImageRequest'); -const { startExpiredFileSweep } = require('./services/Files/process'); const { initializeGitHubSkillSync } = require('./services/Skills/sync'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); +const { startExpiredFileSweep } = require('./services/Files/process'); const { checkMigrations } = require('./services/start/migration'); const optionalJwtAuth = require('./middleware/optionalJwtAuth'); const initializeMCPs = require('./services/initializeMCPs'); @@ -58,6 +59,9 @@ const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); +/** Route admin file-config MIME patterns through a linear-time engine (ReDoS-safe) on upload. */ +configureFileConfigRegexEngine(); + /** Reject messageFilter PII patterns the RE2 runtime engine cannot compile, at config load. */ configureMessageFilterRegexValidator(); diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index 05ebfaffba..b680a18c2d 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -21,7 +21,7 @@ import { isDocumentSupportedProvider, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; -import type { TFile, EndpointFileConfig, FileConfig } from 'librechat-data-provider'; +import type { TFile, EndpointFileConfig, FileConfig, RegexLike } from 'librechat-data-provider'; import type { QueryClient } from '@tanstack/react-query'; import type { ExtendedFile } from '~/common'; @@ -336,7 +336,7 @@ export type UploadOptionContext = { fileSearchAllowedByAgent: boolean; codeAllowedByAgent: boolean; fileConfig: FileConfig | null; - endpointSupportedMimeTypes?: RegExp[]; + endpointSupportedMimeTypes?: RegexLike[]; }; const isProviderAttachType = (type: string, ctx: UploadOptionContext): boolean => { diff --git a/packages/api/src/files/fileConfigRegex.spec.ts b/packages/api/src/files/fileConfigRegex.spec.ts new file mode 100644 index 0000000000..17843b2aad --- /dev/null +++ b/packages/api/src/files/fileConfigRegex.spec.ts @@ -0,0 +1,19 @@ +import { RE2JS } from 're2js'; +import { convertStringsToRegex, setFileConfigRegexCompiler } from 'librechat-data-provider'; + +describe('file-config MIME patterns on a linear-time engine (ReDoS-safe)', () => { + afterAll(() => { + setFileConfigRegexCompiler((pattern) => new RegExp(pattern)); + }); + + it('evaluates a catastrophic admin MIME pattern in bounded time', () => { + setFileConfigRegexCompiler((pattern) => RE2JS.compile(pattern)); + const [matcher] = convertStringsToRegex(['(a+)+$']); + const adversarial = 'a'.repeat(60) + '!'; + const start = process.hrtime.bigint(); + const matched = matcher.test(adversarial); + const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6; + expect(matched).toBe(false); + expect(elapsedMs).toBeLessThan(1000); + }); +}); diff --git a/packages/api/src/files/filter.ts b/packages/api/src/files/filter.ts index 4597dc987e..805aa94bc3 100644 --- a/packages/api/src/files/filter.ts +++ b/packages/api/src/files/filter.ts @@ -1,14 +1,15 @@ import { getEndpointFileConfig, mergeFileConfig, fileConfig } from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; +import type { RegexLike } from 'librechat-data-provider'; import type { ServerRequest } from '~/types'; /** * Checks if a MIME type is supported by the endpoint configuration * @param mimeType - The MIME type to check - * @param supportedMimeTypes - Array of RegExp patterns to match against + * @param supportedMimeTypes - Array of compiled matchers (RegexLike) to test against * @returns True if the MIME type matches any pattern */ -function isMimeTypeSupported(mimeType: string, supportedMimeTypes?: RegExp[]): boolean { +function isMimeTypeSupported(mimeType: string, supportedMimeTypes?: RegexLike[]): boolean { if (!supportedMimeTypes || supportedMimeTypes.length === 0) { return true; } diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index cbf79614ab..985b9cbdcb 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -9,6 +9,7 @@ export * from './mistral/crud'; export * from './ocr'; export * from './parse'; export * from './rag'; +export * from './regexEngine'; export * from './retention'; export * from './sse'; export * from './sweep'; diff --git a/packages/api/src/files/regexEngine.ts b/packages/api/src/files/regexEngine.ts new file mode 100644 index 0000000000..e52e059ce7 --- /dev/null +++ b/packages/api/src/files/regexEngine.ts @@ -0,0 +1,12 @@ +import { RE2JS } from 're2js'; +import { setFileConfigRegexCompiler } from 'librechat-data-provider'; + +/** + * Route admin-configured file-config MIME patterns through a linear-time engine (RE2) on the + * server so a catastrophic-backtracking pattern cannot ReDoS the event loop when tested against + * an uploaded file's Content-Type. Call once at server startup, before any upload is handled. + * Browser builds keep the native compiler, so no regex engine is added to the client bundle. + */ +export function configureFileConfigRegexEngine(): void { + setFileConfigRegexCompiler((pattern) => RE2JS.compile(pattern)); +} diff --git a/packages/data-provider/src/file-config.spec.ts b/packages/data-provider/src/file-config.spec.ts index 3fb851a86c..f295d5ecca 100644 --- a/packages/data-provider/src/file-config.spec.ts +++ b/packages/data-provider/src/file-config.spec.ts @@ -8,6 +8,7 @@ import { isAnthropicDocumentType, isPermissiveMimeConfig, convertStringsToRegex, + setFileConfigRegexCompiler, documentParserMimeTypes, getEndpointFileConfig, applicationMimeTypes, @@ -1614,3 +1615,36 @@ describe('getConfiguredMimeAccept', () => { expect(accept.has('.parquet')).toBe(true); }); }); + +describe('setFileConfigRegexCompiler (MIME pattern compiler seam)', () => { + afterEach(() => { + setFileConfigRegexCompiler((pattern) => new RegExp(pattern)); + }); + + it('defaults to a native RegExp compiler', () => { + const [regex] = convertStringsToRegex(['^text/']); + expect(regex.test('text/csv')).toBe(true); + expect(regex.test('image/png')).toBe(false); + }); + + it('routes patterns through an injected compiler', () => { + const seen: string[] = []; + setFileConfigRegexCompiler((pattern) => { + seen.push(pattern); + return { test: () => false }; + }); + const compiled = convertStringsToRegex(['application/pdf', 'text/*']); + expect(seen).toEqual(['application/pdf', 'text/*']); + expect(compiled.every((matcher) => matcher.test('anything') === false)).toBe(true); + }); + + it('returns a single reject-all matcher when every pattern fails to compile', () => { + setFileConfigRegexCompiler(() => { + throw new Error('unsupported pattern'); + }); + const compiled = convertStringsToRegex(['application/pdf', 'text/*']); + expect(compiled).toHaveLength(1); + expect(compiled[0].test('application/pdf')).toBe(false); + expect(compiled[0].test('anything')).toBe(false); + }); +}); diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index ed9a92ff39..c6985367a3 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { EndpointFileConfig, FileConfig } from './types/files'; +import type { EndpointFileConfig, FileConfig, RegexLike } from './types/files'; import { EModelEndpoint, isAgentsEndpoint, isDocumentSupportedProvider } from './schemas'; import { normalizeEndpointName } from './utils'; @@ -490,7 +490,7 @@ export const fileConfig = { stt: { supportedMimeTypes: defaultSTTMimeTypes, }, - checkType: function (fileType: string, supportedTypes: RegExp[] = supportedMimeTypes) { + checkType: function (fileType: string, supportedTypes: RegexLike[] = supportedMimeTypes) { return supportedTypes.some((regex) => regex.test(fileType)); }, }; @@ -543,20 +543,46 @@ export const fileConfigSchema = z.object({ export type TFileConfig = z.infer; -/** Helper function to safely convert string patterns to RegExp objects */ -export const convertStringsToRegex = (patterns: string[]): RegExp[] => - patterns.reduce((acc: RegExp[], pattern) => { +/** + * Compiler for admin-supplied MIME patterns. Defaults to native `RegExp`, which browser + * builds keep so no extra dependency is bundled. The server swaps in a linear-time engine + * via `setFileConfigRegexCompiler` so an admin-authored catastrophic-backtracking pattern + * cannot ReDoS the shared event loop when tested against an uploaded file's MIME type. + */ +let compileMimeRegex: (pattern: string) => RegexLike = (pattern) => new RegExp(pattern); + +/** Override the MIME-pattern compiler; the server injects a linear-time engine at startup. */ +export const setFileConfigRegexCompiler = (compile: (pattern: string) => RegexLike): void => { + compileMimeRegex = compile; +}; + +/** Returned when every configured pattern fails to compile, so consumers that read an empty + * allowlist as "no restriction" fail closed instead of allowing every file. */ +const rejectAllMimeMatcher: RegexLike = { test: () => false }; + +/** Helper function to safely convert string patterns to matcher objects */ +export const convertStringsToRegex = (patterns: string[]): RegexLike[] => { + const compiled = patterns.reduce((acc: RegexLike[], pattern) => { try { - const regex = new RegExp(pattern); - acc.push(regex); + acc.push(compileMimeRegex(pattern)); } catch (error) { console.error(`Invalid regex pattern "${pattern}" skipped.`, error); } return acc; - }, []); + }, [] as RegexLike[]); + // Every configured pattern was dropped. Return an explicit reject-all matcher so consumers that + // read an empty allowlist as "no restriction" fail closed instead of allowing every file. + if (patterns.length > 0 && compiled.length === 0) { + console.error( + `All ${patterns.length} MIME type pattern(s) were invalid and skipped; the resulting allowlist rejects every file.`, + ); + return [rejectAllMimeMatcher]; + } + return compiled; +}; /** Detects whether the given MIME type patterns accept all file types (e.g., `.*` or `.+`). */ -export const isPermissiveMimeConfig = (types?: RegExp[]): boolean => { +export const isPermissiveMimeConfig = (types?: RegexLike[]): boolean => { if (!types || types.length === 0) { return false; } @@ -714,7 +740,7 @@ const isRepresentable = (mimeType: string): boolean => * picker never hides a file the path would have accepted. */ const buildMimeAccept = ( - types: RegExp[], + types: RegexLike[], { categories, documentMimeTypes }: MimeUploadCapability, ): string | undefined => { const permittedSet = new Set(categories); @@ -792,7 +818,7 @@ const buildMimeAccept = ( * `supportedMimeTypes` on upload. */ export const getConfiguredMimeAccept = ( - types: RegExp[] | undefined, + types: RegexLike[] | undefined, capability: MimeUploadCapability, ): string | undefined => { /** Referential identity with the built-in list signals an unconfigured endpoint (keep provider filter). */ diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts index b889ca6f17..f518df24be 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -39,12 +39,15 @@ export enum FileContext { bytes = 'bytes', } +/** Structural type for a compiled matcher: a native `RegExp` or a linear-time engine both satisfy it. Only `test` is ever called on `supportedMimeTypes`. */ +export type RegexLike = { test(input: string): boolean }; + export type EndpointFileConfig = { disabled?: boolean; fileLimit?: number; fileSizeLimit?: number; totalSizeLimit?: number; - supportedMimeTypes?: RegExp[]; + supportedMimeTypes?: RegexLike[]; }; export type FileConfig = { @@ -64,15 +67,15 @@ export type FileConfig = { quality?: number; }; ocr?: { - supportedMimeTypes?: RegExp[]; + supportedMimeTypes?: RegexLike[]; }; text?: { - supportedMimeTypes?: RegExp[]; + supportedMimeTypes?: RegexLike[]; }; stt?: { - supportedMimeTypes?: RegExp[]; + supportedMimeTypes?: RegexLike[]; }; - checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean; + checkType?: (fileType: string, supportedTypes: RegexLike[]) => boolean; }; export type FileConfigInput = { @@ -99,7 +102,7 @@ export type FileConfigInput = { stt?: { supportedMimeTypes?: string[]; }; - checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean; + checkType?: (fileType: string, supportedTypes: RegexLike[]) => boolean; }; export type TFile = {