Files
Nexus/packages/engine/src/blueprint/validator.ts
T
Hartmut 019702c043 security: ReDoS hardening on blueprint field validator (#52)
Admin-editable blueprint field patterns go through `new RegExp(pattern).test(userValue)`
— a classic ReDoS sink if the admin account is compromised or the
permission is ever delegated. A pattern like `^(a+)+$` against 30
'a's followed by '!' freezes the event loop for seconds per request.

Three layers of defence:
- Save-time: FieldValidationSchema.pattern now has `.max(200)` and a
  `.refine()` that rejects nested-quantifier shapes like `(x+)+`,
  `(?:x*)+`, `(x{2,})*`.
- Runtime (engine/blueprint/validator.ts):
  - isSuspectRegexPattern() runs the same heuristic. If it fires, the
    field fails validation outright — regex is never compiled.
  - Input strings are sliced to 4096 chars before .test() so even a
    benign pattern against a 10 MB payload returns in < 50 ms.
  - RegExp compile failures are caught and treated as validation
    errors rather than crashing the request.

Tests: 10 cases in packages/engine/src/__tests__/blueprint-validator-redos.test.ts,
including the canonical `^(a+)+$` attack — completes in < 50 ms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:33:42 +02:00

208 lines
7.4 KiB
TypeScript

import { FieldType, type BlueprintFieldDefinition } from "@capakraken/shared";
export interface CustomFieldValidationError {
key: string;
message: string;
}
// ReDoS hardening: the blueprint field `pattern` is admin-editable. A
// catastrophic-backtracking pattern like `^(a+)+$` against a crafted input
// can freeze the event loop for multiple seconds per request. We bound the
// attack surface on both axes:
//
// 1. Pattern length capped at 200 chars (see blueprint.schema.ts too).
// 2. Input length capped at 4096 chars before regex.test() — even a bad
// pattern on a short input completes in < 50 ms.
// 3. A cheap heuristic rejects obvious nested-quantifier shapes at
// validation time so malicious patterns simply don't match.
const MAX_PATTERN_LENGTH = 200;
const MAX_REGEX_INPUT_LENGTH = 4_096;
// Heuristic: reject grouped subexpressions that contain a quantifier AND
// are themselves wrapped in an outer quantifier — that's the shape of
// every classical ReDoS pattern ((a+)+, (a|a)*, (.*?)+ etc.). This
// over-approximates: it may reject some benign patterns that happen to
// look this way, which is acceptable for admin-side form validation.
export function isSuspectRegexPattern(pattern: string): boolean {
if (pattern.length > MAX_PATTERN_LENGTH) return true;
// Match: open paren, any non-close-paren chars containing an unbounded
// quantifier (+, *, or {n,}), then close paren, then an outer quantifier
// (+, *, ?, or {).
const nestedQuantifier = /\([^)]*(?:[+*]|\{\d+,\d*\})[^)]*\)[+*?{]/;
return nestedQuantifier.test(pattern);
}
export { MAX_PATTERN_LENGTH, MAX_REGEX_INPUT_LENGTH };
/**
* Validates a `dynamicFields` record against an array of BlueprintFieldDefinitions.
* Returns an array of errors (empty = valid).
*/
export function validateCustomFields(
fieldDefs: BlueprintFieldDefinition[],
dynamicFields: Record<string, unknown>,
): CustomFieldValidationError[] {
const errors: CustomFieldValidationError[] = [];
for (const def of fieldDefs) {
const value = dynamicFields[def.key];
const isEmpty = value === undefined || value === null || value === "";
if (def.required && isEmpty) {
errors.push({ key: def.key, message: `${def.label} is required` });
continue;
}
if (isEmpty) continue;
switch (def.type) {
case FieldType.NUMBER: {
if (typeof value !== "number" && isNaN(Number(value))) {
errors.push({ key: def.key, message: `${def.label} must be a number` });
}
const validation = def.validation;
if (validation) {
const num = Number(value);
if (validation.min !== undefined && num < validation.min) {
errors.push({
key: def.key,
message: `${def.label} must be at least ${validation.min}`,
});
}
if (validation.max !== undefined && num > validation.max) {
errors.push({
key: def.key,
message: `${def.label} must be at most ${validation.max}`,
});
}
}
break;
}
case FieldType.BOOLEAN:
if (value !== true && value !== false && value !== "true" && value !== "false") {
errors.push({ key: def.key, message: `${def.label} must be a boolean` });
}
break;
case FieldType.SELECT:
if (def.options && def.options.length > 0) {
const valid = def.options.some((o) => o.value === value);
if (!valid) {
const allowed = def.options.map((o) => o.label || o.value).join(", ");
errors.push({ key: def.key, message: `${def.label} must be one of: ${allowed}` });
}
}
break;
case FieldType.MULTI_SELECT:
if (Array.isArray(value) && def.options && def.options.length > 0) {
const validSet = new Set(def.options.map((o) => o.value));
const invalid = (value as string[]).filter((v) => !validSet.has(v));
if (invalid.length > 0) {
errors.push({
key: def.key,
message: `${def.label} contains invalid values: ${invalid.join(", ")}`,
});
}
}
break;
case FieldType.URL:
try {
new URL(String(value));
} catch {
errors.push({ key: def.key, message: `${def.label} must be a valid URL` });
}
break;
case FieldType.EMAIL:
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
errors.push({ key: def.key, message: `${def.label} must be a valid email address` });
}
break;
case FieldType.TEXT:
case FieldType.TEXTAREA: {
const strVal = String(value);
const v = def.validation;
if (v) {
if (v.minLength !== undefined && strVal.length < v.minLength) {
errors.push({
key: def.key,
message: v.message ?? `${def.label} must be at least ${v.minLength} characters`,
});
}
if (v.maxLength !== undefined && strVal.length > v.maxLength) {
errors.push({
key: def.key,
message: v.message ?? `${def.label} must be at most ${v.maxLength} characters`,
});
}
if (v.pattern !== undefined) {
// ReDoS defence: reject suspect patterns OUTRIGHT (counts as
// validation failure so the admin sees a clear error) and cap
// the input before regex.test() to bound runtime even if an
// unsafe pattern somehow slipped through save-time validation.
if (isSuspectRegexPattern(v.pattern)) {
errors.push({
key: def.key,
message: v.message ?? `${def.label} pattern rejected (unsafe)`,
});
} else {
const capped =
strVal.length > MAX_REGEX_INPUT_LENGTH
? strVal.slice(0, MAX_REGEX_INPUT_LENGTH)
: strVal;
let matched = false;
try {
matched = new RegExp(v.pattern).test(capped);
} catch {
// Invalid regex syntax — treat as validation failure.
matched = false;
}
if (!matched) {
errors.push({
key: def.key,
message: v.message ?? `${def.label} has an invalid format`,
});
}
}
}
}
break;
}
case FieldType.DATE: {
const dateVal = new Date(String(value));
if (isNaN(dateVal.getTime())) {
errors.push({ key: def.key, message: `${def.label} must be a valid date` });
} else {
const v = def.validation;
if (v) {
if (v.min !== undefined && dateVal.getTime() < new Date(v.min).getTime()) {
errors.push({
key: def.key,
message:
v.message ??
`${def.label} must not be before ${new Date(v.min).toLocaleDateString()}`,
});
}
if (v.max !== undefined && dateVal.getTime() > new Date(v.max).getTime()) {
errors.push({
key: def.key,
message:
v.message ??
`${def.label} must not be after ${new Date(v.max).toLocaleDateString()}`,
});
}
}
}
break;
}
}
}
return errors;
}