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/package-lock.json b/package-lock.json index 441d40922a..73ef8454a2 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", @@ -36478,6 +36479,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", @@ -42703,6 +42713,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.24.1", diff --git a/packages/api/src/middleware/messageFilterPii.spec.ts b/packages/api/src/middleware/messageFilterPii.spec.ts index 04bfb5356a..374714ea1b 100644 --- a/packages/api/src/middleware/messageFilterPii.spec.ts +++ b/packages/api/src/middleware/messageFilterPii.spec.ts @@ -403,4 +403,25 @@ describe('configureMessageFilterRegexValidator (RE2 config-load validation)', () // 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 59f5e8b2e4..b52ca1a958 100644 --- a/packages/api/src/middleware/messageFilterPii.ts +++ b/packages/api/src/middleware/messageFilterPii.ts @@ -64,25 +64,33 @@ 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: RE2JS.compile(p.regex) }); } catch (err) { + dropped += 1; logger.warn( `[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 a config declared patterns but every one failed to compile (e.g. a DB or + // admin override carrying RE2-incompatible syntax that never hit load-time validation): with + // nothing left to enforce, block rather than pass. + const result: CompiledConfig = { patterns, failClosed: patterns.length === 0 && dropped > 0 }; COMPILE_CACHE.set(config, result); return result; } @@ -99,6 +107,8 @@ function findMatch(text: string, patterns: CompiledPattern[]): CompiledPattern | export interface PiiMatch { id: string; label: string; + /** Set when the filter is configured but every pattern failed to compile; block without a real matched label. */ + misconfigured?: boolean; } type ContentPart = { type?: string; text?: string; [key: string]: unknown }; @@ -114,7 +124,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; } @@ -206,7 +219,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;