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 = {