From d753f4563d010f5e03f62472063dc48b408a4dae Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Sat, 15 Aug 2026 03:24:23 -0700 Subject: [PATCH] Avoid corrupting Markdown URL with `<`, `(`, `\` (v2) (#19482) Signed-off-by: Anders Kaseorg Co-authored-by: fisker --- changelog_unreleased/markdown/19482.md | 15 +++++ src/language-markdown/print/mdast.js | 56 ++++++++++--------- .../link/__snapshots__/format.test.js.snap | 24 +++++++- tests/format/markdown/link/encodedLink.md | 12 ++++ 4 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 changelog_unreleased/markdown/19482.md diff --git a/changelog_unreleased/markdown/19482.md b/changelog_unreleased/markdown/19482.md new file mode 100644 index 000000000..8ecd8da15 --- /dev/null +++ b/changelog_unreleased/markdown/19482.md @@ -0,0 +1,15 @@ +#### Avoid corrupting URL with `<`, `(`, `\` (#19482 by @andersk) + +Avoid corrupting a link, image, or link reference definition with with certain characters like `<`, `(`, `\`, by using `<...>` wrapping and `\` escaping as appropriate. + + +```md + +[link](<\<(\\>) + + +[link](<(\) + + +[link](<\<(\\>) +``` diff --git a/src/language-markdown/print/mdast.js b/src/language-markdown/print/mdast.js index 8559c6bf6..a229b0489 100644 --- a/src/language-markdown/print/mdast.js +++ b/src/language-markdown/print/mdast.js @@ -1,5 +1,4 @@ import collapseWhiteSpace from "collapse-white-space"; -import escapeStringRegexp from "escape-string-regexp"; import { align, DOC_TYPE_STRING, @@ -226,7 +225,7 @@ function printMdast(path, options, print) { "](", options.parser !== "mdx" && node.url === "" ? "<>" - : printUrl(node.url, ")"), + : printUrl(node.url, false), printTitle(node.title, options), ")", ]; @@ -243,7 +242,7 @@ function printMdast(path, options, print) { "](", options.parser !== "mdx" && node.url === "" ? "<>" - : printUrl(node.url, ")"), + : printUrl(node.url, false), printTitle(node.title, options), ")", ]; @@ -349,7 +348,7 @@ function printMdast(path, options, print) { lineOrSpace, options.parser !== "mdx" && node.url === "" ? "<>" - : printUrl(node.url), + : printUrl(node.url, true), node.title === null ? "" : [lineOrSpace, printTitle(node.title, options, false)], @@ -516,31 +515,32 @@ function shouldRemainTheSameContent(path) { ); } -const encodeUrl = (url, characters) => { - for (const character of characters) { - url = url.replaceAll(character, encodeURIComponent(character)); - } - return url; -}; - /** * @param {string} url - * @param {string[] | string} [dangerousCharOrChars] + * @param {boolean} unwrapBalancedParens * @returns {string} */ -function printUrl(url, dangerousCharOrChars = []) { - const dangerousChars = [ - " ", - ...(Array.isArray(dangerousCharOrChars) - ? dangerousCharOrChars - : [dangerousCharOrChars]), - ]; +function printUrl(url, unwrapBalancedParens) { + // Backslash followed by ASCII punctuation would be misinterpreted as an + // escape sequence, so must itself be escaped. + url = url.replaceAll(/\\(?![^!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, "\\\\"); - return new RegExp( - dangerousChars.map((x) => escapeStringRegexp(x)).join("|"), - ).test(url) - ? `<${encodeUrl(url, "<>")}>` - : url; + // CommonMark forbids ASCII controls, space, unbalanced parentheses, and + // initial <, unless wrapped in <> with any inner < or > escaped. CommonMark + // only suggests implementations "should" support at least three levels of + // parenthesis nesting, so it's unclear whether we can safely rely on three + // levels as we do here, but we certainly can't expect more. + if ( + // eslint-disable-next-line no-control-regex + /[\x00-\x1f\x7f ]|^])/g, String.raw`\$1`)}>`; + } + + return url; } function printTitle(title, options, printSpace = true) { @@ -556,7 +556,13 @@ function printTitle(title, options, printSpace = true) { title = title.replaceAll(/\\(?=["')])/g, ""); } - if (title.includes('"') && title.includes("'") && !title.includes(")")) { + if ( + title.includes('"') && + title.includes("'") && + !title.includes("(") && + !title.includes(")") + ) { + title = title.replaceAll("\\", "\\\\"); return `(${title})`; // avoid escaped quotes } const quote = getPreferredQuote(title, options.singleQuote); diff --git a/tests/format/markdown/link/__snapshots__/format.test.js.snap b/tests/format/markdown/link/__snapshots__/format.test.js.snap index f896dc414..b85d32994 100644 --- a/tests/format/markdown/link/__snapshots__/format.test.js.snap +++ b/tests/format/markdown/link/__snapshots__/format.test.js.snap @@ -82,35 +82,53 @@ proseWrap: "always" [link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) ![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-) [link]: https://www.google.fr/()foo->bar [link]: https://www.google.fr/foo->bar [link]: https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo- =====================================output===================================== -[link]() +[link](bar>) [link](https://www.google.fr/foo->bar) [link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) [link](<(>) [link](\\\\) [link](< >) -![link]() +![link](bar>) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-) ![link](<(>) ![link](\\\\) +![link](< >) [link]: https://www.google.fr/()foo->bar [link]: https://www.google.fr/foo->bar [link]: https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo- +[link]: <(> +[link]: \\\\ +[link]: < > ================================================================================ `; diff --git a/tests/format/markdown/link/encodedLink.md b/tests/format/markdown/link/encodedLink.md index 73f6aaec6..59d6f233d 100644 --- a/tests/format/markdown/link/encodedLink.md +++ b/tests/format/markdown/link/encodedLink.md @@ -3,15 +3,27 @@ [link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) ![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-) [link]: https://www.google.fr/()foo->bar [link]: https://www.google.fr/foo->bar [link]: https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo-