🛡️ fix: Validate messageFilter patterns with the RE2 engine at config load

Replace the syntax blacklist (numeric/named backreferences, lookaround) with authoritative
validation: config load now compiles each custom pattern with the same linear-time engine the
runtime uses, so any RE2-incompatible construct (including control escapes like \cA) is rejected
at load with a clear error instead of being silently dropped at request time.

The validator is swappable and defaults to native RegExp so browser builds add no engine; the
server wires the RE2-backed check at startup via configureMessageFilterRegexValidator in both
entry points.
This commit is contained in:
Dustin Healy 2026-07-31 13:50:34 -07:00
parent 002f0c57f0
commit 67977a8c05
5 changed files with 83 additions and 18 deletions

View file

@ -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 */

View file

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

View file

@ -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('(?<n>x)\\k<n>')).toBe(false);
expect(reject('token-\\cA+')).toBe(false);
// a normal RE2-compatible pattern still passes
expect(reject('\\bORG-[A-Z0-9]{6,}')).toBe(true);
});
});

View file

@ -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[] = [

View file

@ -1874,29 +1874,36 @@ export type SummarizationConfig = z.infer<typeof summarizationConfigSchema>;
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<name>`) 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) || /\(\?<?[=!]/.test(value)) {
return false;
}
try {
new RegExp(value, 'g');
return true;
} catch {
return false;
}
},
{ message: 'Unsupported regex: backreferences and lookaround are not supported' },
),
.refine((value) => messageFilterRegexValidator(value), {
message:
'Unsupported regex: not compatible with the RE2 engine (no backreferences, lookaround, or control escapes)',
}),
});
export const messageFilterPiiSchema = z.object({