mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-28 10:21:39 +00:00
🛡️ 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:
parent
81b37a1a9e
commit
3ea35af9ad
4 changed files with 132 additions and 1 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue