Files
multica/packages/views/editor/extensions/slash-command-extension.ts
Naiyuan Qing ebd0248be2 MUL-4016: fix mention tokenizer stacktrace backtracking (#4889)
* MUL-4016: fix mention tokenizer stacktrace backtracking

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): de-ambiguate escaped-label regexes to kill ReDoS (MUL-4016)

The mention/slash/file-card label regexes used `(?:\\.|[^\]])` where both
alternatives can consume a backslash. On an unterminated match, each `\x`
run is enumerated 2^n ways — pasting a Java stacktrace (`\~\[...\]`) or a
crafted ~50-char string freezes the main thread for seconds (GitHub #4881).

Exclude backslash from the char class (`[^\]\\]`) so a backslash can only be
consumed by `\\.`. The alternatives become disjoint and matching is linear;
legal escaped-bracket labels like `David\[TF\]` still parse unchanged.

Fixed in all four sites that shared the pattern:
- mention-extension.ts tokenize()
- slash-command-extension.ts start() + tokenize()
- file-card.tsx FILE_CARD_MARKDOWN_RE
- packages/ui/markdown/file-cards.ts NEW_FILE_CARD_RE (runs on every
  read-only comment/description render, not just the editor)

Adds adversarial regression tests (repeated `\a` + missing closing bracket)
that fail in ~10-40s against the old regexes and pass in <1ms after the fix.
Builds on #4889's marker-first mention start().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): escape backslash in mention/slash labels for round-trip (MUL-4016)

Follow-up to the de-ambiguation fix, addressing Howard's PR review.

The linear tokenizer now treats "\" as an escape lead (\\.), so a label whose
serialized form contains a bare "\" adjacent to the closing "]" no longer parses
back — the "\]" is consumed as an escaped bracket and swallows the boundary. The
old ambiguous regex tolerated this by chance; the de-ambiguation exposes it.

mention/slash renderMarkdown escaped only [ and ], not \. Switch both to the
shared escapeMarkdownLabel() (escapes [ ] \ ( )) and mirror it on parse with
replace(/\\([[\]\\()])/g, "$1"), matching what file-card already does. This also
converges the three tokenizers on one escape contract. file-card was already
correct and is unchanged.

Adds parameterized round-trip tests for labels containing "\" / "\]" / parens
(e.g. "A\\", "ends\\", "a\\]b"); these fail on the old serializer and pass now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:43:51 +08:00

73 lines
2.3 KiB
TypeScript

import Mention from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { SlashCommandView } from "./slash-command-view";
import { formatSlashCommandLabel } from "./slash-command-utils";
import { escapeMarkdownLabel } from "../utils/escape-markdown-label";
export const SlashCommandExtension = Mention.extend({
name: "slashCommand",
addNodeView() {
return ReactNodeViewRenderer(SlashCommandView);
},
renderHTML({ node, HTMLAttributes }) {
return [
"span",
mergeAttributes(
{ "data-type": "slash-command" },
this.options.HTMLAttributes,
HTMLAttributes,
),
`/${formatSlashCommandLabel(node.attrs.label)}`,
];
},
addAttributes() {
return { ...this.parent?.() };
},
parseHTML() {
return [{ tag: 'span[data-type="slash-command"]' }];
},
markdownTokenizer: {
name: "slashCommand",
level: "inline" as const,
start(src: string) {
// Backslash is excluded from the char class so "\x" runs can only be
// consumed by \\. — overlapping alternatives backtrack in 2^n ways
// (ReDoS, GitHub #4881).
return src.search(/\[\/(?:\\.|[^\]\\])+\]\(slash:\/\/skill\//);
},
tokenize(src: string) {
const match = src.match(
/^\[\/((?:\\.|[^\]\\])+)\]\(slash:\/\/skill\/([^)]+)\)/,
);
if (!match) return undefined;
// Reverse escapeMarkdownLabel: unescape \[ \] \\ \( \). Must mirror the
// escaped set exactly, or a label containing "\" fails to round-trip
// through the linear tokenizer.
const rawLabel = match[1]?.replace(/\\([[\]\\()])/g, "$1");
return {
type: "slashCommand",
raw: match[0],
attributes: { label: rawLabel, id: match[2] },
};
},
},
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("slashCommand", token.attributes);
},
renderMarkdown: (node: any) => {
const { id, label } = node.attrs || {};
// Escape [ ] \ ( ) so the label survives the linear tokenizer; must stay in
// sync with the unescape in tokenize() above.
const safeLabel = escapeMarkdownLabel(formatSlashCommandLabel(label));
return `[/${safeLabel}](slash://skill/${id})`;
},
});