From 0c959deb991fdf26f97b38d811f15763f717a7dc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 26 Aug 2026 10:10:19 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A2=20fix:=20Cap=20Batched=20HITL=20An?= =?UTF-8?q?swers=20Before=20PII=20and=20Moderation=20(#15257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/middleware/moderateText.js | 5 +-- api/server/middleware/moderateText.spec.js | 35 +++++++++++++++++++ packages/api/src/agents/hitl/resume.spec.ts | 18 ++++++++++ packages/api/src/agents/hitl/resume.ts | 35 +++++++++++++------ .../src/middleware/messageFilterPii.spec.ts | 9 +++++ .../api/src/middleware/messageFilterPii.ts | 10 +++--- 6 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 api/server/middleware/moderateText.spec.js diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js index 9aedf3bd51..0e1b752974 100644 --- a/api/server/middleware/moderateText.js +++ b/api/server/middleware/moderateText.js @@ -3,6 +3,7 @@ const { isEnabled, getReferencedQuotes, mergeQuotedText, + getBoundedAskUserAnswerValues, serializeAskUserAnswerVariants, } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); @@ -42,8 +43,8 @@ async function moderateText(req, res, next) { typeof req.body.answers === 'object' && !Array.isArray(req.body.answers) ) { - for (const answer of Object.values(req.body.answers)) { - if (typeof answer === 'string' && answer.length > 0) { + for (const answer of getBoundedAskUserAnswerValues(req.body.answers)) { + if (answer.length > 0) { inputs.push(answer); } } diff --git a/api/server/middleware/moderateText.spec.js b/api/server/middleware/moderateText.spec.js new file mode 100644 index 0000000000..062f9dc4ef --- /dev/null +++ b/api/server/middleware/moderateText.spec.js @@ -0,0 +1,35 @@ +const axios = require('axios'); +const { getBoundedAskUserAnswerValues, serializeAskUserAnswerVariants } = require('@librechat/api'); + +jest.mock('axios', () => ({ post: jest.fn() })); +jest.mock('winston-daily-rotate-file', () => jest.fn()); +jest.mock('@librechat/api', () => ({ + isEnabled: () => true, + getReferencedQuotes: () => undefined, + mergeQuotedText: jest.fn(), + getBoundedAskUserAnswerValues: jest.fn(() => []), + serializeAskUserAnswerVariants: jest.fn(() => []), +})); +jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn() } })); +jest.mock('librechat-data-provider', () => ({ ErrorTypes: { MODERATION: 'moderation' } })); +jest.mock('./denyRequest', () => jest.fn()); + +const moderateText = require('./moderateText'); + +describe('moderateText', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not submit oversized answer batches to moderation', async () => { + const answers = { environment: 'x'.repeat(16_001), credentials: 'safe' }; + const next = jest.fn(); + + await moderateText({ body: { answers } }, {}, next); + + expect(getBoundedAskUserAnswerValues).toHaveBeenCalledWith(answers); + expect(serializeAskUserAnswerVariants).toHaveBeenCalledWith(answers); + expect(axios.post).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/agents/hitl/resume.spec.ts b/packages/api/src/agents/hitl/resume.spec.ts index 929eeba175..bdb9910f79 100644 --- a/packages/api/src/agents/hitl/resume.spec.ts +++ b/packages/api/src/agents/hitl/resume.spec.ts @@ -10,6 +10,7 @@ import { mapToolApprovalResolutions, mapAskUserAnswer, mapAskUserAnswers, + getBoundedAskUserAnswerValues, serializeAskUserAnswerVariants, resolveAskUserQuestionResume, findUndecidedToolCalls, @@ -104,6 +105,23 @@ describe('serializeAskUserAnswerVariants', () => { five: '5', }), ).toEqual([]); + expect( + serializeAskUserAnswerVariants({ + one: 'x'.repeat(16_001), + two: 'safe', + }), + ).toEqual([]); + }); +}); + +describe('getBoundedAskUserAnswerValues', () => { + it('excludes oversized batches before content middleware can inspect them', () => { + expect( + getBoundedAskUserAnswerValues({ + one: 'x'.repeat(16_001), + two: 'safe', + }), + ).toEqual([]); }); }); diff --git a/packages/api/src/agents/hitl/resume.ts b/packages/api/src/agents/hitl/resume.ts index 833ace36f5..05b344ca9a 100644 --- a/packages/api/src/agents/hitl/resume.ts +++ b/packages/api/src/agents/hitl/resume.ts @@ -69,6 +69,26 @@ const MAX_ASK_ANSWER_LENGTH = 16_000; const ASK_QUESTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const MAX_ASK_QUESTIONS = 4; +function getBoundedAskUserAnswerEntries(answers: unknown): Array<[string, string]> | undefined { + if (answers == null || typeof answers !== 'object' || Array.isArray(answers)) { + return undefined; + } + const entries = Object.entries(answers); + if ( + entries.length === 0 || + entries.length > MAX_ASK_QUESTIONS || + entries.some(([, value]) => typeof value !== 'string' || value.length > MAX_ASK_ANSWER_LENGTH) + ) { + return undefined; + } + return entries as Array<[string, string]>; +} + +/** Return batched ask-user values when their count and length are bounded. */ +export function getBoundedAskUserAnswerValues(answers: unknown): string[] { + return getBoundedAskUserAnswerEntries(answers)?.map(([, value]) => value) ?? []; +} + /** * Serialize every ordering a validated batch can take after the SDK rebuilds * its answer map in question order. Batches are capped at four questions, so @@ -76,22 +96,15 @@ const MAX_ASK_QUESTIONS = 4; * checks inspect the exact ToolMessage even for a crafted key order. */ export function serializeAskUserAnswerVariants(answers: unknown): string[] { - if (answers == null || typeof answers !== 'object' || Array.isArray(answers)) { - return []; - } - const entries = Object.entries(answers); - if ( - entries.length === 0 || - entries.length > MAX_ASK_QUESTIONS || - entries.some(([, value]) => typeof value !== 'string') - ) { + const entries = getBoundedAskUserAnswerEntries(answers); + if (entries == null) { return []; } const variants: string[] = []; - const visit = (remaining: Array<[string, unknown]>, ordered: Array<[string, unknown]>) => { + const visit = (remaining: Array<[string, string]>, ordered: Array<[string, string]>) => { if (remaining.length === 0) { - const normalized = Object.create(null) as Record; + const normalized = Object.create(null) as Record; for (const [key, value] of ordered) { normalized[key] = value; } diff --git a/packages/api/src/middleware/messageFilterPii.spec.ts b/packages/api/src/middleware/messageFilterPii.spec.ts index 11a6c9f2b6..331a29cee4 100644 --- a/packages/api/src/middleware/messageFilterPii.spec.ts +++ b/packages/api/src/middleware/messageFilterPii.spec.ts @@ -306,6 +306,15 @@ describe('messageFilterPii middleware', () => { expect(capturedRes.status).toBe(400); }); + it('does not inspect an oversized batched ask-user answer before resume validation', () => { + const { capturedRes, nextCalls } = runMiddleware( + {}, + { answers: { environment: 'x'.repeat(16_001), credentials: `the key is ${SK}` } }, + ); + expect(nextCalls).toBe(1); + expect(capturedRes.status).toBeUndefined(); + }); + it('rejects a blocked pattern spanning serialized batch answers', () => { const { capturedRes, nextCalls } = runMiddleware( { diff --git a/packages/api/src/middleware/messageFilterPii.ts b/packages/api/src/middleware/messageFilterPii.ts index c201dcc890..17893ad3d0 100644 --- a/packages/api/src/middleware/messageFilterPii.ts +++ b/packages/api/src/middleware/messageFilterPii.ts @@ -31,13 +31,16 @@ import { isContentTraversalProtected, isContentTraversalLimitError, } from '../protection/adapters/nested'; +import { + getBoundedAskUserAnswerValues, + serializeAskUserAnswerVariants, +} from '../agents/hitl/resume'; import { extractFileContent, extractStoredMessageContent, } from '../protection/adapters/submissions'; import { createLegacyPiiInspector, toLegacyPiiMatch } from '../protection/legacy'; import { extractMessageContent } from '../protection/adapters/messages'; -import { serializeAskUserAnswerVariants } from '../agents/hitl/resume'; import { extractChatContent } from '../protection/adapters/chat'; import { contentFilterBlockResponse } from './contentFilter'; import { inspectContent } from '../protection/runtime'; @@ -219,10 +222,9 @@ export function createMessageFilterPii(options: CreateMessageFilterPiiOptions): typeof req.body.answers === 'object' && !Array.isArray(req.body.answers) ) { + const answers = getBoundedAskUserAnswerValues(req.body.answers); const answerCandidates = [ - ...Object.values(req.body.answers).filter( - (answer): answer is string => typeof answer === 'string' && answer.length > 0, - ), + ...answers.filter((answer) => answer.length > 0), ...serializeAskUserAnswerVariants(req.body.answers), ]; fragments.push(