fix: fail closed when messageFilter.pii compiles to zero patterns

DB and admin config overrides bypass the RE2 schema validation (it only
runs at YAML load), so an override whose only pattern is RE2-incompatible
was dropped at compile time, left zero patterns, and let the request
through. compile() now returns a failClosed flag when a config declared
patterns but every one failed to compile; the middleware returns 400 and
findPiiMatchInMessages returns a distinct misconfigured match that the
OpenAI and Responses controllers surface with an admin-facing message.
This commit is contained in:
Dustin Healy 2026-08-02 17:01:35 -07:00
parent 3ae8dbef28
commit 312c81f79b
5 changed files with 63 additions and 7 deletions

View file

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

View file

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

11
package-lock.json generated
View file

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

View file

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

View file

@ -64,25 +64,33 @@ function selectStarter(ids?: string[]): CompiledPattern[] {
return out;
}
const COMPILE_CACHE = new WeakMap<object, CompiledPattern[]>();
type CompiledConfig = { patterns: CompiledPattern[]; failClosed: boolean };
function compile(config: MessageFilterPiiConfig): CompiledPattern[] {
const COMPILE_CACHE = new WeakMap<object, CompiledConfig>();
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;