mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🧢 fix: Cap Batched HITL Answers Before PII and Moderation (#15257)
This commit is contained in:
parent
76762a20c4
commit
0c959deb99
6 changed files with 95 additions and 17 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
api/server/middleware/moderateText.spec.js
Normal file
35
api/server/middleware/moderateText.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue