🛡️ feat: Extend PII filter to OpenAI-compatible and Responses agent APIs

The chat-route middleware operates on `req.body.text`, but the remote
agent API endpoints (`/api/agents/v1/chat/completions`,
`/api/agents/v1/responses`) accept the same prompt content as a
`messages` array or an `input` field. A caller using their API key
could send a credential-shaped value through either route and bypass
the configured PII filter even though they share the same agent and
model backbone the middleware is meant to guard.

Factored out `findPiiMatchInMessages`, a tolerant walker that handles
both `content: string` and `content: ContentPart[]` user-message
shapes against the same compiled, cached pattern list. Wired it into
the OpenAI-compat controller after agent lookup and into the
Responses controller right after `convertToInternalMessages`. Each
returns the endpoint's native 400 error shape
(`sendErrorResponse` / `sendResponsesErrorResponse`) with the
`message_pii_filter_block` code when a user message matches.
This commit is contained in:
Dustin Healy 2026-06-08 12:24:26 -07:00
parent 81b37a1a9e
commit 3ea35af9ad
4 changed files with 132 additions and 1 deletions

View file

@ -33,6 +33,7 @@ const {
resolveAgentScopedSkillIds,
createOpenAIContentAggregator,
isChatCompletionValidationFailure,
findPiiMatchInMessages,
} = require('@librechat/api');
const {
buildSummarizationHandlers,
@ -177,6 +178,17 @@ const OpenAIChatCompletionController = async (req, res) => {
);
}
const piiHit = findPiiMatchInMessages(request.messages, appConfig?.messagePiiFilter);
if (piiHit != null) {
return sendErrorResponse(
res,
400,
`Message contains a ${piiHit.label}. Remove it and try again.`,
'invalid_request_error',
'message_pii_filter_block',
);
}
const responseId = `chatcmpl-${nanoid()}`;
const created = Math.floor(Date.now() / 1000);

View file

@ -41,6 +41,7 @@ const {
sendResponsesErrorResponse,
createResponsesEventHandlers,
createAggregatorEventHandlers,
findPiiMatchInMessages,
} = require('@librechat/api');
const {
createResponsesToolEndCallback,
@ -575,6 +576,17 @@ const createResponse = async (req, res) => {
typeof request.input === 'string' ? request.input : request.input,
);
const piiHit = findPiiMatchInMessages(inputMessages, appConfig?.messagePiiFilter);
if (piiHit != null) {
return sendResponsesErrorResponse(
res,
400,
`Message contains a ${piiHit.label}. Remove it and try again.`,
'invalid_request',
'message_pii_filter_block',
);
}
// Merge previous messages with new input
const allMessages = [...previousMessages, ...inputMessages];

View file

@ -4,7 +4,7 @@ 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 { createMessagePiiFilter } from './messagePiiFilter';
import { createMessagePiiFilter, findPiiMatchInMessages } from './messagePiiFilter';
type CapturedResponse = { status?: number; body?: unknown };
@ -160,3 +160,63 @@ describe('messagePiiFilter middleware', () => {
expect(matching.capturedRes.status).toBe(400);
});
});
describe('findPiiMatchInMessages', () => {
it('returns null for missing or empty messages', () => {
expect(findPiiMatchInMessages(undefined, {})).toBeNull();
expect(findPiiMatchInMessages([], {})).toBeNull();
});
it('returns null when no config is provided', () => {
expect(
findPiiMatchInMessages([{ role: 'user', content: 'sk-proj-FAKE123' }], undefined),
).toBeNull();
});
it('skips non-user messages', () => {
const hit = findPiiMatchInMessages(
[
{ role: 'system', content: 'sk-proj-FAKE1234567890ABCDEF' },
{ role: 'assistant', content: 'sk-proj-FAKE1234567890ABCDEF' },
],
{},
);
expect(hit).toBeNull();
});
it('matches a string-content user message', () => {
const hit = findPiiMatchInMessages(
[{ role: 'user', content: 'my key is sk-proj-FAKE1234567890ABCDEF' }],
{},
);
expect(hit).toEqual({ id: 'sk_prefix', label: 'sk- prefix token' });
});
it('matches a content-parts user message (text part)', () => {
const hit = findPiiMatchInMessages(
[
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'data:...' } },
{ type: 'text', text: 'Authorization: Bearer abc.def.ghi' },
],
},
],
{},
);
expect(hit).toEqual({ id: 'bearer_header', label: 'Bearer token' });
});
it('returns null when no user message matches', () => {
expect(findPiiMatchInMessages([{ role: 'user', content: 'hello world' }], {})).toBeNull();
});
it('honors customPatterns from config', () => {
const hit = findPiiMatchInMessages([{ role: 'user', content: 'token ORG-DEADBEEF here' }], {
starterPatterns: [],
customPatterns: [{ id: 'org', label: 'Org token', regex: '\\bORG-[A-Z0-9]{6,}' }],
});
expect(hit).toEqual({ id: 'org', label: 'Org token' });
});
});

View file

@ -64,6 +64,53 @@ function findMatch(text: string, patterns: CompiledPattern[]): CompiledPattern |
return null;
}
export interface PiiMatch {
id: string;
label: string;
}
type ContentPart = { type?: string; text?: string; [key: string]: unknown };
type ChatLikeMessage = {
role?: string;
content?: string | ContentPart[];
};
export function findPiiMatchInMessages(
messages: ChatLikeMessage[] | undefined,
config: MessagePiiFilterConfig | undefined,
): PiiMatch | null {
if (config == null || !Array.isArray(messages) || messages.length === 0) {
return null;
}
const patterns = compile(config);
if (patterns.length === 0) {
return null;
}
for (const msg of messages) {
if (msg == null || msg.role !== 'user') {
continue;
}
if (typeof msg.content === 'string') {
const hit = findMatch(msg.content, patterns);
if (hit != null) {
return { id: hit.id, label: hit.label };
}
continue;
}
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part != null && typeof part.text === 'string') {
const hit = findMatch(part.text, patterns);
if (hit != null) {
return { id: hit.id, label: hit.label };
}
}
}
}
}
return null;
}
export interface CreateMessagePiiFilterOptions {
getConfig: (req: ServerRequest) => MessagePiiFilterConfig | undefined;
}