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>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|