🚧 fix: Run PII filter before OpenAI moderation on chat endpoint

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.
This commit is contained in:
Dustin Healy 2026-06-07 21:56:29 -07:00
parent f26dff44a2
commit 8a03c1e07c
4 changed files with 74 additions and 33 deletions

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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);