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:
@@ -30,19 +30,37 @@ export const FieldOptionSchema = z.object({
|
||||
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({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
minLength: z.number().int().optional(),
|
||||
maxLength: z.number().int().optional(),
|
||||
pattern: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
pattern: z
|
||||
.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({
|
||||
id: z.string().min(1),
|
||||
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),
|
||||
required: z.boolean().default(false),
|
||||
description: z.string().optional(),
|
||||
@@ -60,12 +78,16 @@ export const CreateBlueprintSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
fieldDefs: z.array(BlueprintFieldDefinitionSchema).default([]),
|
||||
defaults: z.record(z.string(), z.unknown()).default({}),
|
||||
validationRules: z.array(z.object({
|
||||
field: z.string(),
|
||||
rule: z.enum(["required_if", "unique", "min", "max"]),
|
||||
params: z.unknown().optional(),
|
||||
message: z.string().optional(),
|
||||
})).default([]),
|
||||
validationRules: z
|
||||
.array(
|
||||
z.object({
|
||||
field: z.string(),
|
||||
rule: z.enum(["required_if", "unique", "min", "max"]),
|
||||
params: z.unknown().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const UpdateBlueprintSchema = CreateBlueprintSchema.partial();
|
||||
|
||||
Reference in New Issue
Block a user