⏱️ fix: Compile admin file-config MIME patterns on a linear-time engine (ReDoS) (#14555)

* ⏱️ 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.
This commit is contained in:
Dustin Healy 2026-08-06 06:05:42 -07:00 committed by GitHub
parent dd159c4566
commit 0e14d91ed9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 126 additions and 22 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}

View file

@ -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';

View file

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

View file

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

View file

@ -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<typeof fileConfigSchema>;
/** 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<MimeUploadCategory>(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). */

View file

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