mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +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,
|
resolveAgentScopedSkillIds,
|
||||||
createOpenAIContentAggregator,
|
createOpenAIContentAggregator,
|
||||||
isChatCompletionValidationFailure,
|
isChatCompletionValidationFailure,
|
||||||
|
findPiiMatchInMessages,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
buildSummarizationHandlers,
|
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 responseId = `chatcmpl-${nanoid()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const {
|
||||||
sendResponsesErrorResponse,
|
sendResponsesErrorResponse,
|
||||||
createResponsesEventHandlers,
|
createResponsesEventHandlers,
|
||||||
createAggregatorEventHandlers,
|
createAggregatorEventHandlers,
|
||||||
|
findPiiMatchInMessages,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
createResponsesToolEndCallback,
|
createResponsesToolEndCallback,
|
||||||
|
|
@ -575,6 +576,17 @@ const createResponse = async (req, res) => {
|
||||||
typeof request.input === 'string' ? request.input : request.input,
|
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
|
// Merge previous messages with new input
|
||||||
const allMessages = [...previousMessages, ...inputMessages];
|
const allMessages = [...previousMessages, ...inputMessages];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Request, Response, NextFunction } from 'express';
|
||||||
jest.mock('@librechat/data-schemas', () => ({
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() },
|
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 };
|
type CapturedResponse = { status?: number; body?: unknown };
|
||||||
|
|
||||||
|
|
@ -160,3 +160,63 @@ describe('messagePiiFilter middleware', () => {
|
||||||
expect(matching.capturedRes.status).toBe(400);
|
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;
|
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 {
|
export interface CreateMessagePiiFilterOptions {
|
||||||
getConfig: (req: ServerRequest) => MessagePiiFilterConfig | undefined;
|
getConfig: (req: ServerRequest) => MessagePiiFilterConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue