019702c043
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>
170 lines
5.6 KiB
TypeScript
170 lines
5.6 KiB
TypeScript
import { z } from "zod";
|
|
import { BlueprintTarget, FieldType } from "../types/enums.js";
|
|
|
|
// ─── Role Preset Schema ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validates a single staffing requirement used as a blueprint role preset.
|
|
* Must match the StaffingRequirement interface from types/project.ts.
|
|
*/
|
|
export const RolePresetSchema = z.object({
|
|
id: z.string().min(1),
|
|
role: z.string().min(1),
|
|
roleId: z.string().optional(),
|
|
requiredSkills: z.array(z.string()).default([]),
|
|
preferredSkills: z.array(z.string()).optional(),
|
|
hoursPerDay: z.number().positive().max(24),
|
|
headcount: z.number().int().min(1),
|
|
budgetCents: z.number().int().min(0).optional(),
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().optional(),
|
|
notes: z.string().optional(),
|
|
chapter: z.string().optional(),
|
|
});
|
|
|
|
export const RolePresetsSchema = z.array(RolePresetSchema);
|
|
|
|
export const FieldOptionSchema = z.object({
|
|
value: z.string().min(1),
|
|
label: z.string().min(1),
|
|
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()
|
|
.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"),
|
|
type: z.nativeEnum(FieldType),
|
|
required: z.boolean().default(false),
|
|
description: z.string().optional(),
|
|
placeholder: z.string().optional(),
|
|
defaultValue: z.unknown().optional(),
|
|
options: z.array(FieldOptionSchema).optional(),
|
|
validation: FieldValidationSchema.optional(),
|
|
order: z.number().int().min(0),
|
|
group: z.string().optional(),
|
|
});
|
|
|
|
export const CreateBlueprintSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
target: z.nativeEnum(BlueprintTarget),
|
|
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([]),
|
|
});
|
|
|
|
export const UpdateBlueprintSchema = CreateBlueprintSchema.partial();
|
|
|
|
export type CreateBlueprintInput = z.infer<typeof CreateBlueprintSchema>;
|
|
export type UpdateBlueprintInput = z.infer<typeof UpdateBlueprintSchema>;
|
|
|
|
/** Generate a Zod schema from blueprint field definitions at runtime */
|
|
export function generateDynamicZodSchema(
|
|
fieldDefs: z.infer<typeof BlueprintFieldDefinitionSchema>[],
|
|
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
const shape: Record<string, z.ZodTypeAny> = {};
|
|
|
|
for (const field of fieldDefs) {
|
|
let fieldSchema: z.ZodTypeAny;
|
|
|
|
switch (field.type) {
|
|
case FieldType.TEXT:
|
|
case FieldType.TEXTAREA:
|
|
case FieldType.URL:
|
|
case FieldType.EMAIL:
|
|
fieldSchema = z.string();
|
|
if (field.validation?.minLength !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodString).min(field.validation.minLength);
|
|
}
|
|
if (field.validation?.maxLength !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodString).max(field.validation.maxLength);
|
|
}
|
|
if (field.type === FieldType.EMAIL) {
|
|
fieldSchema = (fieldSchema as z.ZodString).email();
|
|
}
|
|
if (field.type === FieldType.URL) {
|
|
fieldSchema = (fieldSchema as z.ZodString).url();
|
|
}
|
|
break;
|
|
case FieldType.NUMBER:
|
|
fieldSchema = z.number();
|
|
if (field.validation?.min !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodNumber).min(field.validation.min);
|
|
}
|
|
if (field.validation?.max !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodNumber).max(field.validation.max);
|
|
}
|
|
break;
|
|
case FieldType.BOOLEAN:
|
|
fieldSchema = z.boolean();
|
|
break;
|
|
case FieldType.DATE:
|
|
fieldSchema = z.coerce.date();
|
|
break;
|
|
case FieldType.SELECT:
|
|
if (field.options && field.options.length > 0) {
|
|
const values = field.options.map((o) => o.value) as [string, ...string[]];
|
|
fieldSchema = z.enum(values);
|
|
} else {
|
|
fieldSchema = z.string();
|
|
}
|
|
break;
|
|
case FieldType.MULTI_SELECT:
|
|
if (field.options && field.options.length > 0) {
|
|
const values = field.options.map((o) => o.value) as [string, ...string[]];
|
|
fieldSchema = z.array(z.enum(values));
|
|
} else {
|
|
fieldSchema = z.array(z.string());
|
|
}
|
|
break;
|
|
default:
|
|
fieldSchema = z.unknown();
|
|
}
|
|
|
|
if (!field.required) {
|
|
fieldSchema = fieldSchema.optional();
|
|
}
|
|
|
|
shape[field.key] = fieldSchema;
|
|
}
|
|
|
|
return z.object(shape);
|
|
}
|