From b0f464a97693e1f3403d9bd94a1fdbff73d64053 Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 17 Aug 2026 22:03:47 -0700 Subject: [PATCH] fix(typescript): eliminate ReDoS in chat parser role-boundary regex The role-boundary regex nested a repetition whose value class overlapped the `\w+=` iteration starter and the separators, so a long attribute run (e.g. `name=aname=a...`) could be partitioned exponentially, causing catastrophic backtracking. JavaScript has no possessive quantifiers (unlike the Python fix in #457), so the nested `+` is removed: the bracket now requires an attribute-like start and swallows the remainder with a single non-overlapping `[^]]*` class, which is structurally linear. Individual attributes are still split by ATTR_RE, so behavior is preserved. Adds a bounded-runtime regression test and a multi-unquoted-attribute test. Fixes #446 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../packages/core/src/parsers/prompty.ts | 11 ++++++++- .../packages/core/tests/parsers.test.ts | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/runtime/typescript/packages/core/src/parsers/prompty.ts b/runtime/typescript/packages/core/src/parsers/prompty.ts index 97bf6cfae..823d6da11 100644 --- a/runtime/typescript/packages/core/src/parsers/prompty.ts +++ b/runtime/typescript/packages/core/src/parsers/prompty.ts @@ -21,9 +21,18 @@ import { import type { Parser } from "../core/interfaces.js"; // Role boundary regex — matches lines like `system:` or `user[name="Alice"]:` +// +// ReDoS hardening: the previous body `((\w+\s*=\s*"?[^"]*"?\s*,?\s*)+)` nested a +// repetition whose value class overlapped the `\w+=` iteration starter and the +// separators, so a long attribute run (e.g. `name=aname=a...`) could be partitioned +// exponentially — catastrophic backtracking. JavaScript has no possessive quantifiers +// (unlike the Python fix in #457), so instead we remove the nested `+`: require an +// attribute-like start (`key=`) and then swallow the remainder of the bracket with a +// single `[^\]]*` class that cannot overlap the closing `]`. This is structurally +// linear. Individual attributes are still split later by ATTR_RE. See #446. const ROLE_NAMES = [...ROLES].filter((r) => r !== "tool").sort().join("|"); const BOUNDARY_RE = new RegExp( - `^\\s*#?\\s*(${ROLE_NAMES})(\\[((\\w+\\s*=\\s*"?[^"]*"?\\s*,?\\s*)+)\\])?\\s*:\\s*$`, + `^\\s*#?\\s*(${ROLE_NAMES})(\\[\\w+\\s*=\\s*[^\\]]*\\])?\\s*:\\s*$`, "i", ); diff --git a/runtime/typescript/packages/core/tests/parsers.test.ts b/runtime/typescript/packages/core/tests/parsers.test.ts index eace48dd4..0cae6725e 100644 --- a/runtime/typescript/packages/core/tests/parsers.test.ts +++ b/runtime/typescript/packages/core/tests/parsers.test.ts @@ -59,6 +59,29 @@ Hello!`; expect(messages[0].metadata.name).toBe("Alice"); }); + it("parses multiple unquoted attributes", async () => { + const rendered = `user[name=Bob, id=7]: +Hi`; + + const messages = await parser.parse(agent, rendered); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe("user"); + expect(messages[0].metadata.name).toBe("Bob"); + expect(messages[0].metadata.id).toBe(7); + }); + + it("is not vulnerable to ReDoS on adversarial role attributes (#446)", async () => { + // Unterminated-quote / unbounded attribute run that triggered catastrophic + // backtracking in the old `"?[^"]*"?` value class. Must complete near-instantly. + const evil = `user[${"name=a".repeat(24)}"`; + const start = performance.now(); + const messages = await parser.parse(agent, `${evil}:\nHi`); + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(1000); + // The adversarial line is not a valid boundary, so it stays as content. + expect(messages).toHaveLength(1); + }); + it("preserves inline markdown images as text", async () => { const rendered = `user: Look at this ![photo](https://example.com/img.png)`;