diff --git a/api/server/experimental.js b/api/server/experimental.js index 2632c2d2bc..aa387616c4 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -24,6 +24,7 @@ const { maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, configureServerTimeouts, + configureMessageFilterRegexValidator, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); @@ -49,6 +50,9 @@ const optionalJwtAuth = require('./middleware/optionalJwtAuth'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); +/** Reject messageFilter PII patterns the RE2 runtime engine cannot compile, at config load. */ +configureMessageFilterRegexValidator(); + const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; /** Allow PORT=0 to be used for automatic free port assignment */ diff --git a/api/server/index.js b/api/server/index.js index a27cedbb50..1401f8b9d6 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -32,6 +32,7 @@ const { configureServerTimeouts, setupGracefulShutdown, updateInterfacePermissions, + configureMessageFilterRegexValidator, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const { @@ -56,6 +57,9 @@ const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); +/** Reject messageFilter PII patterns the RE2 runtime engine cannot compile, at config load. */ +configureMessageFilterRegexValidator(); + const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; // Allow PORT=0 to be used for automatic free port assignment diff --git a/packages/api/src/middleware/messageFilterPii.spec.ts b/packages/api/src/middleware/messageFilterPii.spec.ts index c01fbe6f89..9eb27416fc 100644 --- a/packages/api/src/middleware/messageFilterPii.spec.ts +++ b/packages/api/src/middleware/messageFilterPii.spec.ts @@ -1,10 +1,15 @@ +import { messageFilterPiiSchema, setMessageFilterRegexValidator } from 'librechat-data-provider'; import type { MessageFilterPiiConfig } from 'librechat-data-provider'; import type { Request, Response, NextFunction } from 'express'; jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, })); -import { createMessageFilterPii, findPiiMatchInMessages } from './messageFilterPii'; +import { + createMessageFilterPii, + findPiiMatchInMessages, + configureMessageFilterRegexValidator, +} from './messageFilterPii'; type CapturedResponse = { status?: number; body?: unknown }; @@ -366,3 +371,30 @@ describe('findPiiMatchInMessages', () => { expect(hit).toEqual({ id: 'org', label: 'Org token' }); }); }); + +describe('configureMessageFilterRegexValidator (RE2 config-load validation)', () => { + afterAll(() => { + setMessageFilterRegexValidator((value) => { + try { + new RegExp(value, 'g'); + return true; + } catch { + return false; + } + }); + }); + + it('rejects RE2-incompatible custom patterns at config parse once wired', () => { + configureMessageFilterRegexValidator(); + const reject = (regex: string) => + messageFilterPiiSchema.safeParse({ customPatterns: [{ id: 'a', label: 'A', regex }] }) + .success; + // lookahead, numeric + named backreference, control escape: all valid JS, unsupported by RE2 + expect(reject('(?=x)y')).toBe(false); + expect(reject('(a)\\1')).toBe(false); + expect(reject('(?x)\\k')).toBe(false); + expect(reject('token-\\cA+')).toBe(false); + // a normal RE2-compatible pattern still passes + expect(reject('\\bORG-[A-Z0-9]{6,}')).toBe(true); + }); +}); diff --git a/packages/api/src/middleware/messageFilterPii.ts b/packages/api/src/middleware/messageFilterPii.ts index bce3d7d42b..810800cb64 100644 --- a/packages/api/src/middleware/messageFilterPii.ts +++ b/packages/api/src/middleware/messageFilterPii.ts @@ -1,5 +1,6 @@ import { RE2JS } from 're2js'; import { logger } from '@librechat/data-schemas'; +import { setMessageFilterRegexValidator } from 'librechat-data-provider'; import type { NextFunction, RequestHandler, @@ -9,6 +10,23 @@ import type { import type { MessageFilterPiiConfig } from 'librechat-data-provider'; import { getReferencedQuotes, mergeQuotedText } from '../utils/quotes'; +/** + * Wire the messageFilter PII config validator to the linear-time engine (RE2) so a custom + * pattern the runtime cannot compile is rejected at config load rather than silently dropped at + * request time. Call once at server startup, before config is parsed; browser builds keep the + * native default and pull in no engine. + */ +export function configureMessageFilterRegexValidator(): void { + setMessageFilterRegexValidator((pattern) => { + try { + RE2JS.compile(pattern); + return true; + } catch { + return false; + } + }); +} + type CompiledPattern = { id: string; label: string; pattern: RE2JS }; const STARTER_PATTERNS: CompiledPattern[] = [ diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ea717d3733..e18e01f3fc 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1874,29 +1874,36 @@ export type SummarizationConfig = z.infer; const customEndpointsSchema = z.array(endpointSchema.partial()).optional(); +/** + * Validates a messageFilter PII regex at config load. Defaults to native RegExp so browser + * builds add no extra engine; the server injects a check backed by the linear-time runtime + * engine (RE2) via setMessageFilterRegexValidator, so a pattern the runtime cannot compile + * (backreferences, lookaround, control escapes, and so on) is rejected at load rather than + * silently dropped at request time. + */ +let messageFilterRegexValidator: (pattern: string) => boolean = (value) => { + try { + new RegExp(value, 'g'); + return true; + } catch { + return false; + } +}; + +export const setMessageFilterRegexValidator = (validate: (pattern: string) => boolean): void => { + messageFilterRegexValidator = validate; +}; + const messageFilterPiiCustomPatternSchema = z.object({ id: z.string().min(1), label: z.string().min(1), regex: z .string() .min(1) - .refine( - (value) => { - // The server runs these patterns through a linear-time engine (RE2), which does not - // support backreferences (numeric `\1` or named `\k`) or lookaround. Reject those - // at config load with actionable feedback instead of silently dropping them at runtime. - if (/\\[1-9]/.test(value) || /\\k[<']/.test(value) || /\(\? messageFilterRegexValidator(value), { + message: + 'Unsupported regex: not compatible with the RE2 engine (no backreferences, lookaround, or control escapes)', + }), }); export const messageFilterPiiSchema = z.object({