mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* 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>
73 lines
2.3 KiB
TypeScript
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})`;
|
|
},
|
|
});
|