fix: preserve legacy filters and generated files

This commit is contained in:
Danny Avila 2026-07-26 23:37:35 -04:00
parent 009a0bb493
commit d7a1123c9b
9 changed files with 283 additions and 28 deletions

View file

@ -940,8 +940,10 @@ endpoints:
# # enable all starters (sk_prefix, bearer_header, api_key_header).
# starterPatterns: [sk_prefix, bearer_header, api_key_header]
# # (optional) Operator-defined patterns. Each entry needs id,
# # label, and a regex. Legacy patterns are executed by the same bounded,
# # linear-time engine; unsupported native-only constructs are ignored.
# # label, and a JavaScript-flavor regex. Patterns are checked for unsafe
# # backtracking and fail closed when they cannot be safely evaluated;
# # selected inputs over 64 KiB are also blocked.
# # Prefer `filters.messages` for linear-time patterns and source-aware fields.
# customPatterns:
# - id: anthropic_api_key
# label: Anthropic API key

30
package-lock.json generated
View file

@ -38066,6 +38066,33 @@
"node": ">=4"
}
},
"node_modules/redos-detector": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/redos-detector/-/redos-detector-6.1.4.tgz",
"integrity": "sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==",
"license": "MIT",
"dependencies": {
"regjsparser": "0.13.0"
},
"bin": {
"redos-detector": "bin.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/redos-detector/node_modules/regjsparser": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz",
"integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==",
"license": "BSD-2-Clause",
"dependencies": {
"jsesc": "~3.1.0"
},
"bin": {
"regjsparser": "bin/parser"
}
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
@ -43757,7 +43784,8 @@
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.1.2",
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
"re2js": "^2.8.6"
"re2js": "^2.8.6",
"redos-detector": "^6.1.4"
},
"devDependencies": {
"@babel/preset-env": "^7.29.5",

View file

@ -170,6 +170,7 @@
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.1.2",
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
"re2js": "^2.8.6"
"re2js": "^2.8.6",
"redos-detector": "^6.1.4"
}
}

View file

@ -101,6 +101,22 @@ describe('preflightCodeOutputBatch', () => {
expect(result.every((entry) => entry.downloadFallback === true)).toBe(true);
});
it('preserves configured files above the inspection count as URL fallbacks', async () => {
const prepare = jest.fn(async (input: PrepareCodeOutputInput) => prepared(input));
const result = await preflightCodeOutputBatch({
artifact: artifact(12),
limits: { ...limits, fileLimit: 12 },
prepare,
});
expect(prepare).not.toHaveBeenCalled();
expect(result.map((entry) => entry.file.id)).toEqual(
Array.from({ length: 12 }, (_, index) => `file-${index}`),
);
expect(result.every((entry) => entry.downloadFallback === true)).toBe(true);
});
it('stops default-off downloads at the aggregate budget and falls back without retrying', async () => {
const prepare = jest.fn(async (input: PrepareCodeOutputInput) =>
prepared(input, { buffer: Buffer.alloc(4) }),
@ -160,6 +176,41 @@ describe('preflightCodeOutputBatch', () => {
expect(prepare).not.toHaveBeenCalled();
});
it('rejects active inspection above the hard count even when the configured limit allows it', async () => {
const filters: FiltersConfig = {
files: {
pii: {
fields: ['content'],
starterPatterns: [],
customPatterns: [BLOCK_PATTERN],
uninspectable: 'allow',
},
},
};
const prepare = jest.fn(async (input: PrepareCodeOutputInput) => prepared(input));
const inputArtifact = artifact(12);
const overflowFile = inputArtifact.files?.[10];
if (overflowFile == null) {
throw new Error('Expected an overflow file');
}
const readOverflowContent = jest.fn(() => 'safe');
Object.defineProperty(overflowFile, 'content', { get: readOverflowContent });
await expect(
preflightCodeOutputBatch({
filters,
artifact: inputArtifact,
limits: { ...limits, fileLimit: 12 },
prepare,
}),
).rejects.toMatchObject({
code: 'content_filter_uninspectable',
body: { source: 'file', field: 'content' },
});
expect(readOverflowContent).not.toHaveBeenCalled();
expect(prepare).not.toHaveBeenCalled();
});
it('rejects a late blocked file only after preparing the complete no-write batch', async () => {
const filters: FiltersConfig = {
files: {

View file

@ -93,11 +93,11 @@ function normalizedPreparedMimeType(prepared: PreparedCodeOutput): string {
return prepared.file.type.split(';', 1)[0].trim().toLowerCase();
}
function boundedCountLimit(configured: number, hardLimit: number): number {
function getConfiguredCountLimit(configured: number, fallback: number): number {
if (!Number.isFinite(configured) || configured < 0) {
return hardLimit;
return fallback;
}
return Math.min(configured, hardLimit);
return configured;
}
/**
@ -129,29 +129,33 @@ export async function preflightCodeOutputBatch(
selectedFields.length > 0 && (hasActivePiiPatterns(pii) || pii?.uninspectable === 'block');
const resourceField = selectedFields[0] ?? 'content';
const maxCount = boundedCountLimit(
const configuredMaxCount = getConfiguredCountLimit(
Math.floor(input.limits.fileLimit),
CODE_OUTPUT_PREFLIGHT_MAX_COUNT,
);
const maxBytes = getBoundedCodeOutputByteLimit(input.limits.totalSizeLimit);
const fileSizeLimit = getBoundedCodeOutputByteLimit(input.limits.fileSizeLimit, maxBytes);
const entries: PreparedCodeOutputEntry[] = [];
let countExceeded = false;
let configuredCountExceeded = false;
for (const file of input.artifact?.files ?? []) {
if (file.inherited === true) {
continue;
}
if (entries.length >= maxCount) {
countExceeded = true;
if (entries.length >= configuredMaxCount) {
configuredCountExceeded = true;
break;
}
if (inspectionActive && entries.length >= CODE_OUTPUT_PREFLIGHT_MAX_COUNT) {
throw new UninspectableFileError(resourceField);
}
throwIfContentBlocked(input.filters, extractFileContent(file));
entries.push({
file,
sessionId: file.storage_session_id ?? input.artifact?.session_id,
});
}
if (countExceeded) {
const inspectionCountExceeded = entries.length > CODE_OUTPUT_PREFLIGHT_MAX_COUNT;
if (configuredCountExceeded || inspectionCountExceeded) {
if (inspectionActive) {
throw new UninspectableFileError(resourceField);
}

View file

@ -1,4 +1,5 @@
import { RE2JS } from 're2js';
import { isSafePattern } from 'redos-detector';
import { logger } from '@librechat/data-schemas';
import type { MessageFilterPiiConfig, FilterPiiCustomPatternConfig } from 'librechat-data-provider';
import type { ProtectionFinding, TextContentFragment } from '../types';
@ -36,6 +37,71 @@ const STARTER_PATTERNS: readonly CompiledPattern[] = [
const STARTER_BY_ID = new Map(STARTER_PATTERNS.map((pattern) => [pattern.id, pattern]));
const NATIVE_INSPECTOR_CACHE = new WeakMap<object, PatternContentInspector>();
const LINEAR_INSPECTOR_CACHE = new WeakMap<object, PatternContentInspector>();
const LEGACY_PATTERN_MAX_INPUT_BYTES = 64 * 1024;
const LEGACY_PATTERN_MAX_PATTERN_LENGTH = 512;
const LEGACY_PATTERN_MAX_SCORE = 200;
const LEGACY_PATTERN_ANALYSIS_TIMEOUT_MS = 100;
/**
* Legacy custom patterns retain JavaScript semantics after bounded static
* analysis. Unsafe, unknown, or oversized cases block instead of running an
* unbounded backtracking match against request content.
*/
class ValidatedNativePattern implements TestablePattern {
private readonly pattern: RegExp;
private warnedOversized = false;
constructor(
regex: string,
private readonly id: string,
) {
this.pattern = new RegExp(regex);
}
test(input: string): boolean {
if (Buffer.byteLength(input, 'utf8') > LEGACY_PATTERN_MAX_INPUT_BYTES) {
if (!this.warnedOversized) {
this.warnedOversized = true;
logger.warn(
`[messageFilter.pii] blocking oversized input for customPattern ${JSON.stringify(this.id)}`,
);
}
return true;
}
this.pattern.lastIndex = 0;
return this.pattern.test(input);
}
}
function createValidatedNativePattern(regex: string, id: string): TestablePattern {
if (regex.length > LEGACY_PATTERN_MAX_PATTERN_LENGTH) {
logger.warn(
`[messageFilter.pii] failing closed because customPattern ${JSON.stringify(id)} is too long`,
);
return { test: () => true };
}
const pattern = new ValidatedNativePattern(regex, id);
let analysis: ReturnType<typeof isSafePattern>;
try {
analysis = isSafePattern(regex, {
maxScore: LEGACY_PATTERN_MAX_SCORE,
maxSteps: 20_000,
timeout: LEGACY_PATTERN_ANALYSIS_TIMEOUT_MS,
});
} catch {
logger.warn(
`[messageFilter.pii] failing closed because customPattern ${JSON.stringify(id)} could not be analyzed`,
);
return { test: () => true };
}
if (analysis.safe) {
return pattern;
}
logger.warn(
`[messageFilter.pii] failing closed for unsafe customPattern ${JSON.stringify(id)} (${analysis.error ?? 'unsafe'})`,
);
return { test: () => true };
}
function selectStarter(ids?: readonly string[]): readonly CompiledPattern[] {
if (ids == null) {
@ -100,7 +166,9 @@ export function createPatternContentInspector(
id: pattern.id,
label: pattern.label,
pattern:
options.linearTime === true ? RE2JS.compile(pattern.regex) : new RegExp(pattern.regex),
options.linearTime === true
? RE2JS.compile(pattern.regex)
: createValidatedNativePattern(pattern.regex, pattern.id),
});
} catch (error) {
logger.warn(

View file

@ -139,28 +139,117 @@ describe('legacy content protection', () => {
);
});
it('runs legacy custom patterns through the linear-time engine', () => {
it('preserves JavaScript regex compatibility for legacy custom patterns', () => {
jest.mocked(logger.warn).mockClear();
const nestedQuantifier = {
const config = {
starterPatterns: [],
customPatterns: [{ id: 'nested', label: 'Nested', regex: '(a+)+$' }],
customPatterns: [
{ id: 'lookahead', label: 'Lookahead', regex: '(?=PRIVATE)PRIVATE' },
{ id: 'backreference', label: 'Backreference', regex: '([A-Z]{3})-\\1' },
],
} as MessageFilterPiiConfig;
const nativeOnly = {
expect(
inspectLegacyPii([fragment('external-message.0.content', 'PRIVATE')], config)?.ruleId,
).toBe('lookahead');
expect(
inspectLegacyPii([fragment('external-message.0.content', 'ABC-ABC')], config)?.ruleId,
).toBe('backreference');
expect(
inspectLegacyPii([fragment('external-message.0.content', 'SECRET\u00a0KEY')], {
starterPatterns: [],
customPatterns: [{ id: 'whitespace', label: 'Whitespace', regex: 'SECRET\\sKEY' }],
})?.ruleId,
).toBe('whitespace');
expect(logger.warn).not.toHaveBeenCalled();
});
it('fails closed without executing an unsafe legacy native pattern', () => {
jest.mocked(logger.warn).mockClear();
const config = {
starterPatterns: [],
customPatterns: [
{
id: 'unsafe-native',
label: 'Unsafe native',
regex: '(a+)+$',
},
],
} as MessageFilterPiiConfig;
const finding = inspectLegacyPii([fragment('external-message.0.content', 'safe')], config);
expect(finding?.ruleId).toBe('unsafe-native');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[messageFilter.pii] failing closed for unsafe customPattern "unsafe-native"',
),
);
});
it('fails closed when a valid legacy native pattern cannot be analyzed', () => {
jest.mocked(logger.warn).mockClear();
const config = {
starterPatterns: [],
customPatterns: [
{
id: 'named-backreference',
label: 'Named backreference',
regex: '(?<word>[A-Z]+)-\\k<word>',
},
],
} as MessageFilterPiiConfig;
const finding = inspectLegacyPii([fragment('external-message.0.content', 'safe')], config);
expect(finding?.ruleId).toBe('named-backreference');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[messageFilter.pii] failing closed because customPattern "named-backreference" could not be analyzed',
),
);
});
it('fails closed before compiling an oversized legacy native pattern', () => {
jest.mocked(logger.warn).mockClear();
const config = {
starterPatterns: [],
customPatterns: [
{
id: 'oversized-pattern',
label: 'Oversized pattern',
regex: 'A'.repeat(513),
},
],
} as MessageFilterPiiConfig;
const finding = inspectLegacyPii([fragment('external-message.0.content', 'safe')], config);
expect(finding?.ruleId).toBe('oversized-pattern');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[messageFilter.pii] failing closed because customPattern "oversized-pattern" is too long',
),
);
});
it('fails closed on oversized input before running a legacy native pattern', () => {
jest.mocked(logger.warn).mockClear();
const config = {
starterPatterns: [],
customPatterns: [{ id: 'lookahead', label: 'Lookahead', regex: '(?=PRIVATE)PRIVATE' }],
} as MessageFilterPiiConfig;
expect(
inspectLegacyPii(
[fragment('external-message.0.content', `${'a'.repeat(50_000)}!`)],
nestedQuantifier,
),
).toBeNull();
expect(
inspectLegacyPii([fragment('external-message.0.content', 'PRIVATE')], nativeOnly),
).toBeNull();
const finding = inspectLegacyPii(
[fragment('external-message.0.content', 'a'.repeat(64 * 1024 + 1))],
config,
);
expect(finding?.ruleId).toBe('lookahead');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('[messageFilter.pii] dropping invalid customPattern "lookahead":'),
expect.stringContaining(
'[messageFilter.pii] blocking oversized input for customPattern "lookahead"',
),
);
});
});

View file

@ -46,7 +46,7 @@ export function createLegacyPiiInspector(
if (cached != null) {
return cached;
}
const patternInspector = createPatternContentInspector(config, { linearTime: true });
const patternInspector = createPatternContentInspector(config);
if (!patternInspector.active) {
INACTIVE_LEGACY_CONFIGS.add(config);
return null;

View file

@ -243,6 +243,18 @@ describe('filtersConfigSchema', () => {
customPatterns: [{ id: 'legacy-broken', label: 'Broken', regex: '(' }],
}).success,
).toBe(false);
expect(
messageFilterPiiSchema.safeParse({
customPatterns: [{ id: 'legacy-lookahead', label: 'Lookahead', regex: '(?=LEGACY)LEGACY' }],
}).success,
).toBe(true);
expect(
messageFilterPiiSchema.safeParse({
customPatterns: [
{ id: 'legacy-backreference', label: 'Backreference', regex: '(LEGACY)-\\1' },
],
}).success,
).toBe(true);
expect(
messageFilterPiiSchema.parse({
customPatterns: [{ id: 'legacy', label: 'Legacy', regex: 'LEGACY-[0-9]+', ignored: true }],