import { registerDecorator, type ValidationArguments, type ValidationOptions } from 'class-validator'; type BoundedJsonOptions = { maxDepth?: number; maxKeys?: number; maxStringLength?: number; }; const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); export function IsBoundedJsonObject(options: BoundedJsonOptions = {}, validationOptions?: ValidationOptions) { return (target: object, propertyName: string) => registerDecorator({ name: 'isBoundedJsonObject', target: target.constructor, propertyName, constraints: [options], options: validationOptions, validator: { validate(value: unknown, args: ValidationArguments) { if (value === undefined || value === null) return true; const [constraints] = args.constraints as [BoundedJsonOptions]; return isBoundedJsonValue(value, { maxDepth: constraints.maxDepth ?? 4, maxKeys: constraints.maxKeys ?? 100, maxStringLength: constraints.maxStringLength ?? 2_000, }); }, defaultMessage(args: ValidationArguments) { return `${args.property} contains too many, too deeply nested, or unsafe values`; }, }, }); } function isBoundedJsonValue(value: unknown, limits: Required) { let keyCount = 0; const visit = (current: unknown, depth: number): boolean => { if (depth > limits.maxDepth) return false; if (current == null || typeof current === 'boolean' || typeof current === 'number') return true; if (typeof current === 'string') return current.length <= limits.maxStringLength; if (Array.isArray(current)) { keyCount += current.length; return keyCount <= limits.maxKeys && current.every((item) => visit(item, depth + 1)); } if (typeof current !== 'object') return false; const entries = Object.entries(current as Record); keyCount += entries.length; return ( keyCount <= limits.maxKeys && entries.every(([key, item]) => key.length <= 128 && !FORBIDDEN_KEYS.has(key) && visit(item, depth + 1)) ); }; return visit(value, 0); }