From 8a03c1e07c6359e28c2327d288ed78bfb6a6b34e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:56:29 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20fix:=20Run=20PII=20filter=20befo?= =?UTF-8?q?re=20OpenAI=20moderation=20on=20chat=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that the chat-path pre-redaction lived inside AgentController, which Express runs after the moderateText middleware mounted at api/server/routes/agents/chat.js:28. When OPENAI_MODERATION is enabled, moderateText posts req.body.text to the OpenAI moderations endpoint before the PII filter ever sees the request, so a credential in the prompt leaves the server unredacted regardless of the configured mode. The browser-side prefilter catches the common case but any caller that posts directly to the chat endpoint bypasses it. Extracted a messagePiiFilter middleware in api/server/middleware that reads req.config.messagePiiFilter, mutates req.body.text in place for warn and silent, returns a 400 via denyRequest for block (mirroring how moderateText rejects flagged content), and attaches the matches array on req._piiPreRedactMatches for the warn-mode SSE emit later in the controller. Mounted it on the chat router before moderateText, so the moderation endpoint, the agent run, and MongoDB all see the already-redacted text. Trimmed the now-duplicate pre-redaction and block-via-getReqData throw from the controller; it now just reads req._piiPreRedactMatches for the GenerationJobManager.emitChunk and updateMetadata calls that surface the warn toast across replicas. The OpenAI-compat and Responses endpoints keep their controller-level pre-redaction added in the previous commit; those routes do not mount moderateText, so the middleware extraction is not needed there. --- api/server/controllers/agents/request.js | 42 ++++------------ api/server/middleware/index.js | 2 + api/server/middleware/messagePiiFilter.js | 61 +++++++++++++++++++++++ api/server/routes/agents/chat.js | 2 + 4 files changed, 74 insertions(+), 33 deletions(-) create mode 100644 api/server/middleware/messagePiiFilter.js diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index c8369fa27e..5eaf096646 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -10,7 +10,6 @@ const { decrementPendingRequest, sanitizeMessageForTransmit, checkAndIncrementPendingRequest, - applyMessagePiiRedaction, } = require('@librechat/api'); const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup'); const { handleAbortError } = require('~/server/middleware'); @@ -121,28 +120,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const userId = req.user.id; - // Pre-redact PII before user message is constructed/saved so the - // persisted message, the `created` SSE event, and the prompt sent - // to the LLM all carry the redacted text. The agents-side hook - // remains the redaction site for block mode (which denies rather - // than rewrites). - let piiPreRedactMatches = null; - let piiBlockReason = null; - const piiConfig = req.config?.messagePiiFilter; - if (piiConfig != null && typeof piiConfig.onMatch === 'string') { - const result = applyMessagePiiRedaction(text, piiConfig); - if (result.matches.length > 0) { - // All modes redact the persisted/displayed user message so the - // raw credential never reaches MongoDB or the `created` SSE event. - // Mode differs only in what happens after redaction. - text = result.text; - req.body.text = result.text; - if (piiConfig.onMatch === 'warn') { - piiPreRedactMatches = result.matches; - } else if (piiConfig.onMatch === 'block') { - piiBlockReason = result.matches.map((m) => m.patternLabel).join(', '); - } - } + // Pre-redaction + block-mode rejection run in the + // `messagePiiFilter` middleware before `moderateText` so any + // credential-shaped text is scrubbed (or refused) before it can + // leave the server toward the moderation endpoint. The middleware + // mutates `req.body.text` in place and attaches the matches array + // here for the post-job SSE emit. + const piiPreRedactMatches = req._piiPreRedactMatches ?? null; + if (piiPreRedactMatches != null) { + text = req.body.text; } /** When to generate the conversation title. `immediate` (default) fires title @@ -323,16 +309,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit userMessage = data.userMessage; } // conversationId is pre-generated, no need to update from callback - - // Block-mode abort. BaseClient calls getReqData twice: the first time - // before saveMessageToDatabase fires (data.userMessage is set), the - // second time after (data.userMessagePromise is set). Throw on the - // SECOND call so the already-redacted user message has been queued for - // persistence; then the throw propagates up through sendMessage's - // catch and routes through the existing emitError path. - if (piiBlockReason != null && data.userMessagePromise != null) { - throw new Error(`Message blocked by PII filter: ${piiBlockReason}. Edit and retry.`); - } }; // Start background generation - readyPromise resolves immediately now diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 3aa52e3349..4165f11c3e 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -14,6 +14,7 @@ const requireJwtAuth = require('./requireJwtAuth'); const configMiddleware = require('./config/app'); const validateModel = require('./validateModel'); const moderateText = require('./moderateText'); +const messagePiiFilter = require('./messagePiiFilter'); const logHeaders = require('./logHeaders'); const setHeaders = require('./setHeaders'); const validate = require('./validate'); @@ -35,6 +36,7 @@ module.exports = { setHeaders, logHeaders, moderateText, + messagePiiFilter, validateModel, requireJwtAuth, setTwoFactorTempUser, diff --git a/api/server/middleware/messagePiiFilter.js b/api/server/middleware/messagePiiFilter.js new file mode 100644 index 0000000000..4747e9c8af --- /dev/null +++ b/api/server/middleware/messagePiiFilter.js @@ -0,0 +1,61 @@ +const { logger } = require('@librechat/data-schemas'); +const { applyMessagePiiRedaction } = require('@librechat/api'); +const denyRequest = require('./denyRequest'); + +/** + * Pre-redact `req.body.text` before downstream middleware sees it. Mounted + * before `moderateText` on the agent chat route so credentials that match + * a configured `messagePiiFilter` pattern never leave the server toward + * the OpenAI moderation endpoint, the agent run, or MongoDB. + * + * `block` mode returns a 400 here via `denyRequest`, mirroring how + * `moderateText` rejects flagged content. `warn` mutates the text and + * attaches the matches on `req._piiPreRedactMatches` so the controller + * can emit the `pii_matches` SSE event for the warn toast after the + * job (and its streamId) exist. `silent` mutates without attaching. + * + * Runs in addition to the browser-side prefilter in `useClientPiiFilter` + * to cover API callers and any UI bypass. + */ +async function messagePiiFilter(req, res, next) { + const config = req.config?.messagePiiFilter; + if (config == null || typeof config.onMatch !== 'string') { + return next(); + } + const text = req.body?.text; + if (typeof text !== 'string' || text.length === 0) { + return next(); + } + try { + const result = applyMessagePiiRedaction(text, config); + if (result.matches.length === 0) { + return next(); + } + if (config.onMatch === 'block') { + const labels = result.matches.map((m) => m.patternLabel).join(', '); + logger.info( + `[messagePiiFilter] blocked send (patterns=${result.matches + .map((m) => m.patternId) + .join(',')})`, + ); + return await denyRequest(req, res, { + message: `Message blocked by PII filter: ${labels}. Edit and retry.`, + }); + } + req.body.text = result.text; + if (config.onMatch === 'warn') { + req._piiPreRedactMatches = result.matches; + } + logger.info( + `[messagePiiFilter] redacted ${result.matches.length} match(es) (mode=${config.onMatch}, patterns=${result.matches + .map((m) => m.patternId) + .join(',')})`, + ); + return next(); + } catch (err) { + logger.error('[messagePiiFilter] middleware error:', err); + return next(); + } +} + +module.exports = messagePiiFilter; diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index 0543b0b1aa..531a944821 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -3,6 +3,7 @@ const { generateCheckAccess, skipAgentCheck } = require('@librechat/api'); const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider'); const { moderateText, + messagePiiFilter, // validateModel, validateConvoAccess, buildEndpointOption, @@ -25,6 +26,7 @@ const checkAgentResourceAccess = canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW, }); +router.use(messagePiiFilter); router.use(moderateText); router.use(checkAgentAccess); router.use(checkAgentResourceAccess);