From 4dd0e20297dfa9de19d2fd7158ca557438a5c1d4 Mon Sep 17 00:00:00 2001 From: Wenjix Date: Mon, 26 Oct 2026 23:06:55 -0700 Subject: [PATCH] fix(markup): remove exponential backtracking from reference validation The reference attribute regex admitted `.` both inside segments and as the repeated group separator, so a malformed dotted path could be partitioned exponentially many ways before rejection. A 40-segment reference took minutes on the synchronous parse thread. Require `.` strictly between segments: each character now has exactly one consumption path. This also rejects previously tolerated empty or trailing segments (`a.`, `a..b`), which could never resolve to a record. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/markup/src/syntax.ts | 5 ++++- packages/markup/test/syntax.test.ts | 32 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/markup/test/syntax.test.ts diff --git a/packages/markup/src/syntax.ts b/packages/markup/src/syntax.ts index 75f5f74ee..0a7dfb4b5 100644 --- a/packages/markup/src/syntax.ts +++ b/packages/markup/src/syntax.ts @@ -11,7 +11,10 @@ import type { const NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*/u; const ALIAS = /^[a-z][a-z0-9_-]{0,63}$/u; -const REFERENCE = /^[A-Za-z_][A-Za-z0-9_.:-]*(?:\.[A-Za-z_][A-Za-z0-9_.:-]*)*$/u; +// Strictly segmented: `.` separates segments and can never appear inside one. An inner +// character class that also accepts `.` lets a rejecting input partition its dots between +// two quantified parts, which is exponential backtracking on a synchronous parse thread. +const REFERENCE = /^[A-Za-z_][A-Za-z0-9_:-]*(?:\.[A-Za-z_][A-Za-z0-9_:-]*)*$/u; type OpeningTag = { readonly name: string; diff --git a/packages/markup/test/syntax.test.ts b/packages/markup/test/syntax.test.ts new file mode 100644 index 000000000..6ae06bad4 --- /dev/null +++ b/packages/markup/test/syntax.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MarkupFrontendError } from "../src/error.js"; +import { parseOpeningTag } from "../src/syntax.js"; + +const source = (text: string) => ({ name: "test.markup", text }); + +test("reference attributes accept dotted segment paths", () => { + const parsed = parseOpeningTag(source(``), 0); + assert.deepEqual(parsed.attributes["value"], { kind: "reference", path: "timeline.start" }); + assert.deepEqual(parsed.attributes["other"], { kind: "reference", path: "ns:record-1.x" }); +}); + +test("reference attributes reject empty or trailing segments", () => { + for (const path of ["a.", "a..b", ".a", "a.b."]) { + assert.throws( + () => parseOpeningTag(source(``), 0), + (error: unknown) => error instanceof MarkupFrontendError && error.code === "MARKUP_REFERENCE", + ); + } +}); + +test("a long malformed reference rejects without exponential backtracking", () => { + const path = `a${".b".repeat(40)}!`; + const startedAt = Date.now(); + assert.throws( + () => parseOpeningTag(source(``), 0), + (error: unknown) => error instanceof MarkupFrontendError && error.code === "MARKUP_REFERENCE", + ); + assert(Date.now() - startedAt < 1000, "malformed reference validation took too long"); +});