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); }); }); });