diff --git a/api/package.json b/api/package.json index 752ad2ab80..1e19efac3b 100644 --- a/api/package.json +++ b/api/package.json @@ -122,6 +122,7 @@ "pdfjs-dist": "^5.4.624", "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", + "re2js": "^2.8.6", "sanitize-html": "^2.17.6", "sharp": "^0.35.3", "ua-parser-js": "^1.0.36", diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index fa2cfec0cd..e014331acf 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -189,7 +189,9 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { return sendErrorResponse( res, 400, - `Message contains a ${piiHit.label}. Remove it and try again.`, + piiHit.misconfigured + ? 'Message filtering is misconfigured; contact your administrator.' + : `Message contains a ${piiHit.label}. Remove it and try again.`, 'invalid_request_error', 'message_filter_pii_block', ); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index cd29798dee..7429359cd6 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -622,7 +622,9 @@ const executeResponse = async (envelope, { req, res }) => { return sendResponsesErrorResponse( res, 400, - `Message contains a ${piiHit.label}. Remove it and try again.`, + piiHit.misconfigured + ? 'Message filtering is misconfigured; contact your administrator.' + : `Message contains a ${piiHit.label}. Remove it and try again.`, 'invalid_request', 'message_filter_pii_block', ); 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/librechat.example.yaml b/librechat.example.yaml index 4622cf2b68..60e5c952c2 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -905,9 +905,12 @@ endpoints: # # (optional) Pick a subset of the starter catalog by id; omit to # # enable all starters (sk_prefix, bearer_header, api_key_header). # starterPatterns: [sk_prefix, bearer_header, api_key_header] -# # (optional) Operator-defined patterns. Each entry needs id, -# # label, and a JavaScript-flavor regex; the regex is validated -# # at config load time. +# # (optional) Operator-defined patterns. Each entry needs id, label, +# # and a regex in RE2 syntax and semantics (RE2 is a linear-time engine +# # with no catastrophic backtracking; a few escapes such as \p, \A, and +# # \s differ from JavaScript). Backreferences and lookaround are not +# # supported; the regex is validated against the RE2 engine at config +# # load time and a pattern it cannot compile is rejected. # customPatterns: # - id: anthropic_api_key # label: Anthropic API key diff --git a/package-lock.json b/package-lock.json index dd0a8327af..f133e3ae52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,7 @@ "pdfjs-dist": "^5.4.624", "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", + "re2js": "^2.8.6", "sanitize-html": "^2.17.6", "sharp": "^0.35.3", "ua-parser-js": "^1.0.36", @@ -36479,6 +36480,15 @@ "react-dom": ">=16.9.0" } }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -42742,6 +42752,7 @@ "pdfjs-dist": "^5.4.624", "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", + "re2js": "^2.8.6", "sanitize-html": "^2.17.6", "sharp": "^0.35.3", "undici": "^7.29.0", diff --git a/packages/api/package.json b/packages/api/package.json index c769ae3570..0a86914278 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -152,6 +152,7 @@ "pdfjs-dist": "^5.4.624", "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", + "re2js": "^2.8.6", "sanitize-html": "^2.17.6", "sharp": "^0.35.3", "undici": "^7.29.0", diff --git a/packages/api/src/middleware/messageFilterPii.spec.ts b/packages/api/src/middleware/messageFilterPii.spec.ts index fc5c4f3ff6..093a68e66a 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 }; @@ -89,6 +94,19 @@ describe('messageFilterPii middleware', () => { expect(capturedRes.status).toBe(400); }); + it.each([ + ['U+00A0 no-break space', 0x00a0], + ['U+000B vertical tab', 0x000b], + ['U+2028 line separator', 0x2028], + ['U+2029 paragraph separator', 0x2029], + ['U+FEFF zero-width no-break space', 0xfeff], + ])('rejects an api-key header separated by %s', (_label, code) => { + const ws = String.fromCharCode(code); + const { capturedRes, nextCalls } = runMiddleware({}, { text: `api-key:${ws}foo123bar` }); + expect(nextCalls).toBe(0); + expect(capturedRes.status).toBe(400); + }); + const SK = 'sk-proj-FAKE1234567890ABCDEF'; it('rejects a resume ask-user answer containing a blocked token', () => { @@ -231,21 +249,86 @@ describe('messageFilterPii middleware', () => { expect(b.nextCalls).toBe(1); }); - it('drops an invalid customPattern regex without throwing and keeps other patterns active', () => { - const config = { + it('fails closed when a custom pattern fails to compile, blocking even benign text', () => { + const config: MessageFilterPiiConfig = { starterPatterns: [], customPatterns: [ { id: 'broken', label: 'Broken', regex: '(' }, { id: 'org', label: 'Org token', regex: '\\bORG-[A-Z0-9]{6,}' }, ], - } as unknown as MessageFilterPiiConfig; + }; + // A dropped pattern means the config no longer enforces what the operator declared, so + // every request is blocked rather than silently enforcing only the surviving subset. const benign = runMiddleware(config, { text: 'plain text' }); - expect(benign.nextCalls).toBe(1); - expect(benign.capturedRes.status).toBeUndefined(); + expect(benign.nextCalls).toBe(0); + expect(benign.capturedRes.status).toBe(400); const matching = runMiddleware(config, { text: 'token ORG-DEADBEEF here' }); expect(matching.nextCalls).toBe(0); expect(matching.capturedRes.status).toBe(400); }); + + it('evaluates a catastrophic-backtracking customPattern in bounded time', () => { + // `(a+)+$` against a long non-terminating run is exponential on a backtracking + // engine (native RegExp takes tens of seconds at ~32 chars); the linear-time + // engine returns immediately, so this must not hang. + const config: MessageFilterPiiConfig = { + starterPatterns: [], + customPatterns: [{ id: 'evil', label: 'Evil', regex: '(a+)+$' }], + }; + const adversarial = 'a'.repeat(60) + '!'; + const start = process.hrtime.bigint(); + const { nextCalls } = runMiddleware(config, { text: adversarial }); + const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6; + expect(nextCalls).toBe(1); + expect(elapsedMs).toBeLessThan(1000); + }); + + it('still matches a catastrophic-shaped pattern against matching input', () => { + const config: MessageFilterPiiConfig = { + starterPatterns: [], + customPatterns: [{ id: 'evil', label: 'Evil', regex: '(a+)+$' }], + }; + const { capturedRes, nextCalls } = runMiddleware(config, { text: 'a'.repeat(20) }); + expect(nextCalls).toBe(0); + expect(capturedRes.status).toBe(400); + }); + + it('fails closed when a custom pattern uses engine-unsupported syntax', () => { + const config: MessageFilterPiiConfig = { + starterPatterns: [], + customPatterns: [ + { id: 'backref', label: 'Backref', regex: '(a)\\1' }, + { id: 'org', label: 'Org token', regex: '\\bORG-[A-Z0-9]{6,}' }, + ], + }; + const benign = runMiddleware(config, { text: 'plain text' }); + expect(benign.nextCalls).toBe(0); + expect(benign.capturedRes.status).toBe(400); + const matching = runMiddleware(config, { text: 'token ORG-DEADBEEF here' }); + expect(matching.nextCalls).toBe(0); + expect(matching.capturedRes.status).toBe(400); + }); + + it('fails closed on a dropped custom pattern even when default starters remain', () => { + // The partial-drop case: with starterPatterns omitted the three defaults survive, so the + // pattern set is non-empty; failing closed must key off the drop, not an empty set, or the + // dropped rule's target passes silently. + const config: MessageFilterPiiConfig = { + customPatterns: [{ id: 'dup', label: 'Duplicate', regex: '(a)\\1' }], + }; + const { capturedRes, nextCalls } = runMiddleware(config, { text: 'aa' }); + expect(nextCalls).toBe(0); + expect(capturedRes.status).toBe(400); + expect(capturedRes.body).toMatchObject({ error: 'message_filter_pii_block' }); + }); + + it('findPiiMatchInMessages flags misconfigured on a dropped pattern under default starters', () => { + const config: MessageFilterPiiConfig = { + customPatterns: [{ id: 'dup', label: 'Duplicate', regex: '(a)\\1' }], + }; + const hit = findPiiMatchInMessages([{ role: 'user', content: 'aa' }], config); + expect(hit?.misconfigured).toBe(true); + }); }); describe('findPiiMatchInMessages', () => { @@ -320,3 +403,51 @@ 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); + }); + + it('fails closed with 400 when every configured pattern fails to compile', () => { + const { capturedRes, nextCalls } = runMiddleware( + { + starterPatterns: [], + customPatterns: [{ id: 'backref', label: 'Backref', regex: '(a)\\1' }], + }, + { text: 'anything at all' }, + ); + expect(nextCalls).toBe(0); + expect(capturedRes.status).toBe(400); + expect(capturedRes.body).toMatchObject({ error: 'message_filter_pii_block' }); + }); + + it('findPiiMatchInMessages returns a misconfigured match when every pattern fails to compile', () => { + const hit = findPiiMatchInMessages([{ role: 'user', content: 'hello' }], { + starterPatterns: [], + customPatterns: [{ id: 'backref', label: 'Backref', regex: '(a)\\1' }], + }); + expect(hit?.misconfigured).toBe(true); + }); +}); diff --git a/packages/api/src/middleware/messageFilterPii.ts b/packages/api/src/middleware/messageFilterPii.ts index b2140950c9..85094ce368 100644 --- a/packages/api/src/middleware/messageFilterPii.ts +++ b/packages/api/src/middleware/messageFilterPii.ts @@ -1,4 +1,6 @@ +import { RE2JS } from 're2js'; import { logger } from '@librechat/data-schemas'; +import { setMessageFilterRegexValidator } from 'librechat-data-provider'; import type { NextFunction, RequestHandler, @@ -8,12 +10,42 @@ import type { import type { MessageFilterPiiConfig } from 'librechat-data-provider'; import { getReferencedQuotes, mergeQuotedText } from '../utils/quotes'; -type CompiledPattern = { id: string; label: string; pattern: RegExp }; +/** + * 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 WHITESPACE = '\\s\\p{Zs}\\x0B\\x{2028}\\x{2029}\\x{FEFF}'; const STARTER_PATTERNS: CompiledPattern[] = [ - { id: 'sk_prefix', label: 'sk- prefix token', pattern: /\b(sk-)[a-zA-Z0-9_-]+/g }, - { id: 'bearer_header', label: 'Bearer token', pattern: /\b(Bearer )[^\s"']+/gi }, - { id: 'api_key_header', label: 'api-key header', pattern: /\b(api-key:?\s+)[^\s"']+/gi }, + { id: 'sk_prefix', label: 'sk- prefix token', pattern: RE2JS.compile('\\b(sk-)[a-zA-Z0-9_-]+') }, + { + id: 'bearer_header', + label: 'Bearer token', + pattern: RE2JS.compile(`\\b(Bearer )[^${WHITESPACE}"']+`, RE2JS.CASE_INSENSITIVE), + }, + { + id: 'api_key_header', + label: 'api-key header', + pattern: RE2JS.compile( + `\\b(api-key:?[${WHITESPACE}]+)[^${WHITESPACE}"']+`, + RE2JS.CASE_INSENSITIVE, + ), + }, ]; const STARTER_BY_ID = new Map(STARTER_PATTERNS.map((p) => [p.id, p])); @@ -32,32 +64,40 @@ function selectStarter(ids?: string[]): CompiledPattern[] { return out; } -const COMPILE_CACHE = new WeakMap(); +type CompiledConfig = { patterns: CompiledPattern[]; failClosed: boolean }; -function compile(config: MessageFilterPiiConfig): CompiledPattern[] { +const COMPILE_CACHE = new WeakMap(); + +function compile(config: MessageFilterPiiConfig): CompiledConfig { const cached = COMPILE_CACHE.get(config); if (cached != null) { return cached; } const starter = selectStarter(config.starterPatterns); const custom: CompiledPattern[] = []; + let dropped = 0; for (const p of config.customPatterns ?? []) { try { - custom.push({ id: p.id, label: p.label, pattern: new RegExp(p.regex, 'g') }); + custom.push({ id: p.id, label: p.label, pattern: RE2JS.compile(p.regex) }); } catch (err) { + dropped += 1; logger.warn( - `[messageFilter.pii] dropping invalid customPattern ${JSON.stringify(p.id)}: ${(err as Error).message}`, + `[messageFilter.pii] dropping invalid or unsupported customPattern ${JSON.stringify(p.id)}: ${(err as Error).message}`, ); } } - const result = [...starter, ...custom]; + const patterns = [...starter, ...custom]; + // Fail closed when any declared custom pattern fails to compile (e.g. a DB or admin override + // carrying RE2-incompatible syntax that never hit load-time validation): a silently dropped + // pattern would let the text it was meant to catch pass even when other patterns survive, so + // block rather than enforce an unintended subset. + const result: CompiledConfig = { patterns, failClosed: dropped > 0 }; COMPILE_CACHE.set(config, result); return result; } function findMatch(text: string, patterns: CompiledPattern[]): CompiledPattern | null { for (const p of patterns) { - p.pattern.lastIndex = 0; if (p.pattern.test(text)) { return p; } @@ -68,6 +108,8 @@ function findMatch(text: string, patterns: CompiledPattern[]): CompiledPattern | export interface PiiMatch { id: string; label: string; + /** Set when a configured custom pattern failed to compile; block without a real matched label. */ + misconfigured?: boolean; } type ContentPart = { type?: string; text?: string; [key: string]: unknown }; @@ -83,7 +125,10 @@ export function findPiiMatchInMessages( if (config == null || !Array.isArray(messages) || messages.length === 0) { return null; } - const patterns = compile(config); + const { patterns, failClosed } = compile(config); + if (failClosed) { + return { id: '__misconfigured__', label: 'restricted value', misconfigured: true }; + } if (patterns.length === 0) { return null; } @@ -175,7 +220,14 @@ export function createMessageFilterPii(options: CreateMessageFilterPiiOptions): next(); return; } - const patterns = compile(config); + const { patterns, failClosed } = compile(config); + if (failClosed) { + res.status(400).json({ + error: 'message_filter_pii_block', + message: 'Message filtering is misconfigured; contact your administrator.', + }); + return; + } if (patterns.length === 0) { next(); return; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 05304e958c..38959f5cde 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1883,23 +1883,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) => { - try { - new RegExp(value, 'g'); - return true; - } catch { - return false; - } - }, - { message: 'Invalid regex' }, - ), + .refine((value) => messageFilterRegexValidator(value), { + message: + 'Unsupported regex: not compatible with the RE2 engine (no backreferences, lookaround, or control escapes)', + }), }); export const messageFilterPiiSchema = z.object({