mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🚧 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:
parent
f26dff44a2
commit
8a03c1e07c
4 changed files with 74 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
61
api/server/middleware/messagePiiFilter.js
Normal file
61
api/server/middleware/messagePiiFilter.js
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue