🧢 fix: Cap Batched HITL Answers Before PII and Moderation (#15257)

This commit is contained in:
Danny Avila 2026-08-26 10:10:19 -04:00 • committed by GitHub
parent 76762a20c4
commit 0c959deb99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 95 additions and 17 deletions

View file

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

View file

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

View file

@ -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([]);
});
});

View file

@ -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<string, unknown>;
const normalized = Object.create(null) as Record<string, string>;
for (const [key, value] of ordered) {
normalized[key] = value;
}

View file

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

View file

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