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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { FieldType, type BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
|
import {
|
||||||
|
isSuspectRegexPattern,
|
||||||
|
validateCustomFields,
|
||||||
|
MAX_PATTERN_LENGTH,
|
||||||
|
MAX_REGEX_INPUT_LENGTH,
|
||||||
|
} from "../blueprint/validator.js";
|
||||||
|
|
||||||
|
describe("blueprint validator — ReDoS hardening (#52)", () => {
|
||||||
|
describe("isSuspectRegexPattern", () => {
|
||||||
|
it("flags classic nested-quantifier shapes", () => {
|
||||||
|
expect(isSuspectRegexPattern("(a+)+")).toBe(true);
|
||||||
|
expect(isSuspectRegexPattern("(a*)*")).toBe(true);
|
||||||
|
expect(isSuspectRegexPattern("(a+)*")).toBe(true);
|
||||||
|
expect(isSuspectRegexPattern("(a*)+")).toBe(true);
|
||||||
|
expect(isSuspectRegexPattern("(.+)*")).toBe(true);
|
||||||
|
expect(isSuspectRegexPattern("(.*)+")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags grouped bounded-quantifier shapes", () => {
|
||||||
|
expect(isSuspectRegexPattern("(a{2,})+")).toBe(true);
|
||||||
|
expect(isSuspectRegexPattern("(a{2,5})*")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags the canonical ReDoS sample ^(a+)+$", () => {
|
||||||
|
expect(isSuspectRegexPattern("^(a+)+$")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags non-capturing groups too", () => {
|
||||||
|
expect(isSuspectRegexPattern("(?:a+)+")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags over-long patterns (DoS via compile cost)", () => {
|
||||||
|
const long = "a".repeat(MAX_PATTERN_LENGTH + 1);
|
||||||
|
expect(isSuspectRegexPattern(long)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows common safe patterns", () => {
|
||||||
|
expect(isSuspectRegexPattern("^[a-z]+$")).toBe(false);
|
||||||
|
expect(isSuspectRegexPattern("^\\d{3}-\\d{4}$")).toBe(false);
|
||||||
|
expect(isSuspectRegexPattern("[A-Z0-9_]+")).toBe(false);
|
||||||
|
expect(isSuspectRegexPattern("^https?://")).toBe(false);
|
||||||
|
expect(isSuspectRegexPattern("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCustomFields with ReDoS pattern", () => {
|
||||||
|
const fieldDefs: BlueprintFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "f1",
|
||||||
|
label: "Test Field",
|
||||||
|
key: "test",
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
required: false,
|
||||||
|
order: 0,
|
||||||
|
validation: { pattern: "^(a+)+$" },
|
||||||
|
} as BlueprintFieldDefinition,
|
||||||
|
];
|
||||||
|
|
||||||
|
it("rejects a suspect pattern immediately without running RegExp", () => {
|
||||||
|
// Craft the classic ReDoS input: many 'a's followed by a non-matching
|
||||||
|
// char. If the code ran RegExp.test unguarded, this would hang for
|
||||||
|
// seconds. Because the pattern is rejected at validation time, we
|
||||||
|
// get a fast failure.
|
||||||
|
const attackInput = "a".repeat(30) + "!";
|
||||||
|
const t0 = Date.now();
|
||||||
|
const errors = validateCustomFields(fieldDefs, { test: attackInput });
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.key).toBe("test");
|
||||||
|
// Must complete in < 50 ms — well below the budget set by the
|
||||||
|
// ticket's acceptance criteria.
|
||||||
|
expect(elapsed).toBeLessThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still validates benign patterns correctly", () => {
|
||||||
|
const safeFieldDefs: BlueprintFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
...fieldDefs[0]!,
|
||||||
|
validation: { pattern: "^[a-z]+$" },
|
||||||
|
} as BlueprintFieldDefinition,
|
||||||
|
];
|
||||||
|
expect(validateCustomFields(safeFieldDefs, { test: "hello" })).toEqual([]);
|
||||||
|
const errors = validateCustomFields(safeFieldDefs, { test: "HELLO" });
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps input length before regex.test() (belt-and-suspenders)", () => {
|
||||||
|
// Even with a benign pattern, a 10 MB input would be slow to match.
|
||||||
|
// The validator slices to MAX_REGEX_INPUT_LENGTH first.
|
||||||
|
const safeFieldDefs: BlueprintFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
...fieldDefs[0]!,
|
||||||
|
validation: { pattern: "^[a-z]+$" },
|
||||||
|
} as BlueprintFieldDefinition,
|
||||||
|
];
|
||||||
|
const huge = "a".repeat(MAX_REGEX_INPUT_LENGTH * 3);
|
||||||
|
const t0 = Date.now();
|
||||||
|
const errors = validateCustomFields(safeFieldDefs, { test: huge });
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(elapsed).toBeLessThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles syntactically-invalid patterns without throwing", () => {
|
||||||
|
const badFieldDefs: BlueprintFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
...fieldDefs[0]!,
|
||||||
|
validation: { pattern: "[unclosed" },
|
||||||
|
} as BlueprintFieldDefinition,
|
||||||
|
];
|
||||||
|
const errors = validateCustomFields(badFieldDefs, { test: "any" });
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,35 @@ export interface CustomFieldValidationError {
|
|||||||
message: 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.
|
* Validates a `dynamicFields` record against an array of BlueprintFieldDefinitions.
|
||||||
* Returns an array of errors (empty = valid).
|
* Returns an array of errors (empty = valid).
|
||||||
@@ -35,10 +64,16 @@ export function validateCustomFields(
|
|||||||
if (validation) {
|
if (validation) {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (validation.min !== undefined && num < validation.min) {
|
if (validation.min !== undefined && num < validation.min) {
|
||||||
errors.push({ key: def.key, message: `${def.label} must be at least ${validation.min}` });
|
errors.push({
|
||||||
|
key: def.key,
|
||||||
|
message: `${def.label} must be at least ${validation.min}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (validation.max !== undefined && num > validation.max) {
|
if (validation.max !== undefined && num > validation.max) {
|
||||||
errors.push({ key: def.key, message: `${def.label} must be at most ${validation.max}` });
|
errors.push({
|
||||||
|
key: def.key,
|
||||||
|
message: `${def.label} must be at most ${validation.max}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -65,7 +100,10 @@ export function validateCustomFields(
|
|||||||
const validSet = new Set(def.options.map((o) => o.value));
|
const validSet = new Set(def.options.map((o) => o.value));
|
||||||
const invalid = (value as string[]).filter((v) => !validSet.has(v));
|
const invalid = (value as string[]).filter((v) => !validSet.has(v));
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
errors.push({ key: def.key, message: `${def.label} contains invalid values: ${invalid.join(", ")}` });
|
errors.push({
|
||||||
|
key: def.key,
|
||||||
|
message: `${def.label} contains invalid values: ${invalid.join(", ")}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -90,13 +128,46 @@ export function validateCustomFields(
|
|||||||
const v = def.validation;
|
const v = def.validation;
|
||||||
if (v) {
|
if (v) {
|
||||||
if (v.minLength !== undefined && strVal.length < v.minLength) {
|
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` });
|
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) {
|
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` });
|
errors.push({
|
||||||
|
key: def.key,
|
||||||
|
message: v.message ?? `${def.label} must be at most ${v.maxLength} characters`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (v.pattern !== undefined && !new RegExp(v.pattern).test(strVal)) {
|
if (v.pattern !== undefined) {
|
||||||
errors.push({ key: def.key, message: v.message ?? `${def.label} has an invalid format` });
|
// 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;
|
break;
|
||||||
@@ -110,10 +181,20 @@ export function validateCustomFields(
|
|||||||
const v = def.validation;
|
const v = def.validation;
|
||||||
if (v) {
|
if (v) {
|
||||||
if (v.min !== undefined && dateVal.getTime() < new Date(v.min).getTime()) {
|
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()}` });
|
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()) {
|
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()}` });
|
errors.push({
|
||||||
|
key: def.key,
|
||||||
|
message:
|
||||||
|
v.message ??
|
||||||
|
`${def.label} must not be after ${new Date(v.max).toLocaleDateString()}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,37 @@ export const FieldOptionSchema = z.object({
|
|||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ReDoS defence: patterns are admin-editable and get passed to `new RegExp`
|
||||||
|
// at field-validation time. Cap the length and reject obviously-unsafe
|
||||||
|
// shapes at save time. Same heuristic as
|
||||||
|
// @capakraken/engine::isSuspectRegexPattern; kept in-sync to avoid a
|
||||||
|
// shared→engine dep cycle.
|
||||||
|
const RE_DOS_SAFE_PATTERN = /\([^)]*(?:[+*]|\{\d+,\d*\})[^)]*\)[+*?{]/;
|
||||||
|
|
||||||
export const FieldValidationSchema = z.object({
|
export const FieldValidationSchema = z.object({
|
||||||
min: z.number().optional(),
|
min: z.number().optional(),
|
||||||
max: z.number().optional(),
|
max: z.number().optional(),
|
||||||
minLength: z.number().int().optional(),
|
minLength: z.number().int().optional(),
|
||||||
maxLength: z.number().int().optional(),
|
maxLength: z.number().int().optional(),
|
||||||
pattern: z.string().optional(),
|
pattern: z
|
||||||
message: z.string().optional(),
|
.string()
|
||||||
|
.max(200, "Pattern too long (max 200 chars) — ReDoS defence")
|
||||||
|
.refine(
|
||||||
|
(p) => !RE_DOS_SAFE_PATTERN.test(p),
|
||||||
|
"Pattern has nested quantifiers and could cause catastrophic backtracking",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
message: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BlueprintFieldDefinitionSchema = z.object({
|
export const BlueprintFieldDefinitionSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
label: z.string().min(1).max(200),
|
label: z.string().min(1).max(200),
|
||||||
key: z.string().min(1).max(100).regex(/^[a-z_][a-z0-9_]*$/, "Must be snake_case"),
|
key: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-z_][a-z0-9_]*$/, "Must be snake_case"),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
required: z.boolean().default(false),
|
required: z.boolean().default(false),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -60,12 +78,16 @@ export const CreateBlueprintSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
fieldDefs: z.array(BlueprintFieldDefinitionSchema).default([]),
|
fieldDefs: z.array(BlueprintFieldDefinitionSchema).default([]),
|
||||||
defaults: z.record(z.string(), z.unknown()).default({}),
|
defaults: z.record(z.string(), z.unknown()).default({}),
|
||||||
validationRules: z.array(z.object({
|
validationRules: z
|
||||||
field: z.string(),
|
.array(
|
||||||
rule: z.enum(["required_if", "unique", "min", "max"]),
|
z.object({
|
||||||
params: z.unknown().optional(),
|
field: z.string(),
|
||||||
message: z.string().optional(),
|
rule: z.enum(["required_if", "unique", "min", "max"]),
|
||||||
})).default([]),
|
params: z.unknown().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateBlueprintSchema = CreateBlueprintSchema.partial();
|
export const UpdateBlueprintSchema = CreateBlueprintSchema.partial();
|
||||||
|
|||||||
Reference in New Issue
Block a user