From 3ea35af9ad9f259331bc3379f253a26d72f6aa03 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:24:26 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20feat:=20Extend=20PII=20?= =?UTF-8?q?filter=20to=20OpenAI-compatible=20and=20Responses=20agent=20API?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/server/controllers/agents/openai.js | 12 ++++ api/server/controllers/agents/responses.js | 12 ++++ .../src/middleware/messagePiiFilter.spec.ts | 62 ++++++++++++++++++- .../api/src/middleware/messagePiiFilter.ts | 47 ++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 89422b2eaa..313b51ced8 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 9022c1e6f0..a230b8f988 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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]; diff --git a/packages/api/src/middleware/messagePiiFilter.spec.ts b/packages/api/src/middleware/messagePiiFilter.spec.ts index b702434aba..babc3642fa 100644 --- a/packages/api/src/middleware/messagePiiFilter.spec.ts +++ b/packages/api/src/middleware/messagePiiFilter.spec.ts @@ -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' }); + }); +}); diff --git a/packages/api/src/middleware/messagePiiFilter.ts b/packages/api/src/middleware/messagePiiFilter.ts index 7698fd4bfb..1ce893dd61 100644 --- a/packages/api/src/middleware/messagePiiFilter.ts +++ b/packages/api/src/middleware/messagePiiFilter.ts @@ -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; }