From fc089286b2233ab23120e99b3b354c2cac8eef2c Mon Sep 17 00:00:00 2001 From: mck09 Date: Sat, 15 Aug 2026 17:13:26 +0200 Subject: [PATCH 1/6] fix: escape character references in autolink destinations Character references are not resolved inside an autolink, so its destination and text are literal, but both were written into the output unescaped. A destination containing a valid reference such as `<` was decoded again by the browser, so the link pointed somewhere other than what was written. Mark autolink and extended url tokens and escape every `&` when rendering them. Inline links keep the source text, which is already valid in an attribute, so they are unchanged. --- src/Renderer.ts | 16 +++++++++++----- src/Tokenizer.ts | 2 ++ src/Tokens.ts | 5 +++++ test/unit/Lexer.test.js | 4 ++++ test/unit/marked.test.js | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/Renderer.ts b/src/Renderer.ts index 890ac2c4ec..a8750e52b3 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -157,18 +157,24 @@ export class _Renderer { return `${this.parser.parseInline(tokens)}` as RendererOutput; } - link({ href, title, tokens }: Tokens.Link): RendererOutput { - const text = this.parser.parseInline(tokens) as string; + link({ href, title, text, tokens, autolink }: Tokens.Link): RendererOutput { + // Character references are not resolved inside an autolink, so its + // destination and text are literal and every `&` has to be escaped. + // Elsewhere the destination still holds the source text, which is already + // valid in an attribute. + const parsedText = autolink + ? escapeHtmlEntities(text, true) + : this.parser.parseInline(tokens) as string; const cleanHref = cleanUrl(href); if (cleanHref === null) { - return text as RendererOutput; + return parsedText as RendererOutput; } - href = cleanHref; + href = autolink ? escapeHtmlEntities(cleanHref, true) : cleanHref; let out = ''; + out += '>' + parsedText + ''; return out as RendererOutput; } diff --git a/src/Tokenizer.ts b/src/Tokenizer.ts index d4176e0650..2ffb010d50 100644 --- a/src/Tokenizer.ts +++ b/src/Tokenizer.ts @@ -914,6 +914,7 @@ export class _Tokenizer { raw: cap[0], text, href, + autolink: true, tokens: [ { type: 'text', @@ -951,6 +952,7 @@ export class _Tokenizer { raw: cap[0], text, href, + autolink: true, tokens: [ { type: 'text', diff --git a/src/Tokens.ts b/src/Tokens.ts index 35c058691d..afc5dd75ee 100644 --- a/src/Tokens.ts +++ b/src/Tokens.ts @@ -136,6 +136,11 @@ export namespace Tokens { title?: string | null; text: string; tokens: Token[]; + /** + * Set for autolinks and extended (GFM) urls, where character references are + * not resolved, so the destination and text are literal. + */ + autolink?: boolean; } export interface List { diff --git a/test/unit/Lexer.test.js b/test/unit/Lexer.test.js index 8064ff59b4..0afbd256b2 100644 --- a/test/unit/Lexer.test.js +++ b/test/unit/Lexer.test.js @@ -2042,6 +2042,7 @@ paragraph raw: '', text: 'https://example.com', href: 'https://example.com', + autolink: true, tokens: [ { type: 'text', @@ -2064,6 +2065,7 @@ paragraph raw: '', text: 'test@example.com', href: 'mailto:test@example.com', + autolink: true, tokens: [ { type: 'text', @@ -2085,6 +2087,7 @@ paragraph raw: 'https://example.com', text: 'https://example.com', href: 'https://example.com', + autolink: true, tokens: [ { type: 'text', @@ -2107,6 +2110,7 @@ paragraph raw: 'test@example.com', text: 'test@example.com', href: 'mailto:test@example.com', + autolink: true, tokens: [ { type: 'text', diff --git a/test/unit/marked.test.js b/test/unit/marked.test.js index 07e58f8578..0127da4a81 100644 --- a/test/unit/marked.test.js +++ b/test/unit/marked.test.js @@ -10,6 +10,43 @@ describe('marked unit', () => { setOptions(getDefaults()); }); + describe('Character references in link destinations', () => { + // Character references are not resolved inside an autolink, so a `&` there + // is literal and has to be escaped. These assert on the exact output + // because the spec runner compares parsed HTML, where `<` and `&lt;` + // in an attribute are indistinguishable. + // https://github.com/markedjs/marked/issues/4052 + + it('should escape an ampersand in an autolink', () => { + assert.strictEqual( + marked.parse('').trim(), + '

https://example.com/?x=1&lt;2

', + ); + }); + + it('should escape an ampersand in an extended url', () => { + assert.strictEqual( + marked.parse('https://example.com/?x=1<2').trim(), + '

https://example.com/?x=1&lt;2

', + ); + }); + + it('should keep the destination of an inline link unchanged', () => { + assert.strictEqual( + marked.parse('[t](https://example.com/?a=1&b=2)').trim(), + '

t

', + ); + }); + + it('should match the CommonMark autolink example', () => { + // https://spec.commonmark.org/0.31.2/#example-595 + assert.strictEqual( + marked.parse('').trim(), + '

https://foo.bar.baz/test?q=hello&id=22&boolean

', + ); + }); + }); + describe('Test paragraph token type', () => { it('should use the "paragraph" type on top level', () => { const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n'; From 509451cdfc6fce5ef665d0f1a43e944f6982803b Mon Sep 17 00:00:00 2001 From: mck09 Date: Mon, 17 Aug 2026 15:20:07 +0200 Subject: [PATCH 2/6] fix: escape an ampersand that cannot start a reference An inline link destination keeps its source text, so a reference such as `<` is already correct in an attribute, but a bare `&` is not. Escape the ones that cannot begin a reference, in image sources as well, and keep escaping everything inside an autolink. --- src/Renderer.ts | 10 ++++------ test/unit/marked.test.js | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Renderer.ts b/src/Renderer.ts index a8750e52b3..b04adc3a3a 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -158,10 +158,8 @@ export class _Renderer { } link({ href, title, text, tokens, autolink }: Tokens.Link): RendererOutput { - // Character references are not resolved inside an autolink, so its - // destination and text are literal and every `&` has to be escaped. - // Elsewhere the destination still holds the source text, which is already - // valid in an attribute. + // References are not resolved inside an autolink, so every `&` there is + // literal. Elsewhere only an `&` that cannot start one needs escaping. const parsedText = autolink ? escapeHtmlEntities(text, true) : this.parser.parseInline(tokens) as string; @@ -169,7 +167,7 @@ export class _Renderer { if (cleanHref === null) { return parsedText as RendererOutput; } - href = autolink ? escapeHtmlEntities(cleanHref, true) : cleanHref; + href = escapeHtmlEntities(cleanHref, autolink); let out = ' { } href = cleanHref; - let out = `${escapeHtmlEntities(text)} { ); }); + it('should escape an ampersand that cannot start a reference', () => { + assert.strictEqual( + marked.parse('[example](http://example.com?foo=1&bar=2)').trim(), + '

example

', + ); + }); + + it('should escape an ampersand in an image source', () => { + assert.strictEqual( + marked.parse('![i](http://example.com?foo=1&bar=2)').trim(), + '

i

', + ); + }); + + it('should leave a reference in an inline destination alone', () => { + assert.strictEqual( + marked.parse('[t](http://example.com?a=1<2)').trim(), + '

t

', + ); + }); + it('should match the CommonMark autolink example', () => { // https://spec.commonmark.org/0.31.2/#example-595 assert.strictEqual( From cbb6c53df5df3b163b53d57803e7a5ef046ba5f9 Mon Sep 17 00:00:00 2001 From: mck09 Date: Tue, 18 Aug 2026 07:24:16 +0200 Subject: [PATCH 3/6] test: cover link destination escaping with spec fixtures --- .../link_destination_character_references.html | 7 +++++++ .../new/link_destination_character_references.md | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 test/specs/new/link_destination_character_references.html create mode 100644 test/specs/new/link_destination_character_references.md diff --git a/test/specs/new/link_destination_character_references.html b/test/specs/new/link_destination_character_references.html new file mode 100644 index 0000000000..1624c5f4d9 --- /dev/null +++ b/test/specs/new/link_destination_character_references.html @@ -0,0 +1,7 @@ +

https://example.com/?x=1&lt;2

+

https://example.com/?x=1&lt;2

+

t

+

example

+

i

+

t

+

https://foo.bar.baz/test?q=hello&id=22&boolean

diff --git a/test/specs/new/link_destination_character_references.md b/test/specs/new/link_destination_character_references.md new file mode 100644 index 0000000000..9f1daa9f26 --- /dev/null +++ b/test/specs/new/link_destination_character_references.md @@ -0,0 +1,16 @@ +--- +renderExact: true +--- + + +https://example.com/?x=1<2 + +[t](https://example.com/?a=1&b=2) + +[example](http://example.com?foo=1&bar=2) + +![i](http://example.com?foo=1&bar=2) + +[t](http://example.com?a=1<2) + + From 5a1b3aa1cbfd50df12587443091d73713fcd1ca4 Mon Sep 17 00:00:00 2001 From: mck09 Date: Tue, 18 Aug 2026 07:42:27 +0200 Subject: [PATCH 4/6] test: drop unit tests now covered by the spec fixtures --- test/unit/marked.test.js | 58 ---------------------------------------- 1 file changed, 58 deletions(-) diff --git a/test/unit/marked.test.js b/test/unit/marked.test.js index 084cee1f06..07e58f8578 100644 --- a/test/unit/marked.test.js +++ b/test/unit/marked.test.js @@ -10,64 +10,6 @@ describe('marked unit', () => { setOptions(getDefaults()); }); - describe('Character references in link destinations', () => { - // Character references are not resolved inside an autolink, so a `&` there - // is literal and has to be escaped. These assert on the exact output - // because the spec runner compares parsed HTML, where `<` and `&lt;` - // in an attribute are indistinguishable. - // https://github.com/markedjs/marked/issues/4052 - - it('should escape an ampersand in an autolink', () => { - assert.strictEqual( - marked.parse('').trim(), - '

https://example.com/?x=1&lt;2

', - ); - }); - - it('should escape an ampersand in an extended url', () => { - assert.strictEqual( - marked.parse('https://example.com/?x=1<2').trim(), - '

https://example.com/?x=1&lt;2

', - ); - }); - - it('should keep the destination of an inline link unchanged', () => { - assert.strictEqual( - marked.parse('[t](https://example.com/?a=1&b=2)').trim(), - '

t

', - ); - }); - - it('should escape an ampersand that cannot start a reference', () => { - assert.strictEqual( - marked.parse('[example](http://example.com?foo=1&bar=2)').trim(), - '

example

', - ); - }); - - it('should escape an ampersand in an image source', () => { - assert.strictEqual( - marked.parse('![i](http://example.com?foo=1&bar=2)').trim(), - '

i

', - ); - }); - - it('should leave a reference in an inline destination alone', () => { - assert.strictEqual( - marked.parse('[t](http://example.com?a=1<2)').trim(), - '

t

', - ); - }); - - it('should match the CommonMark autolink example', () => { - // https://spec.commonmark.org/0.31.2/#example-595 - assert.strictEqual( - marked.parse('').trim(), - '

https://foo.bar.baz/test?q=hello&id=22&boolean

', - ); - }); - }); - describe('Test paragraph token type', () => { it('should use the "paragraph" type on top level', () => { const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n'; From f148d5bd7b947291f7a246b70be8457fbea9e0c6 Mon Sep 17 00:00:00 2001 From: mck09 Date: Tue, 18 Aug 2026 12:48:57 +0200 Subject: [PATCH 5/6] test: use the suggested link destination cases --- ...link_destination_character_references.html | 18 ++++++++--- .../link_destination_character_references.md | 32 +++++++++++++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/test/specs/new/link_destination_character_references.html b/test/specs/new/link_destination_character_references.html index 1624c5f4d9..5e37641032 100644 --- a/test/specs/new/link_destination_character_references.html +++ b/test/specs/new/link_destination_character_references.html @@ -1,7 +1,15 @@

https://example.com/?x=1&lt;2

+

https://example.com/?y=1&amp;2

+

https://example.com/?a=1&b=2

https://example.com/?x=1&lt;2

-

t

-

example

-

i

-

t

-

https://foo.bar.baz/test?q=hello&id=22&boolean

+

https://example.com/?y=1&amp;2

+

https://example.com/?a=1&b=2

+

https://example.com/?x=1<2

+

https://example.com/?y=1&2

+

https://example.com/?a=1&b=2

+

https://example.com/?x=1<2

+

https://example.com/?y=1&2

+

https://example.com/?a=1&b=2

+

https://example.com/?x=1<2

+

https://example.com/?y=1&2

+

https://example.com/?a=1&b=2

diff --git a/test/specs/new/link_destination_character_references.md b/test/specs/new/link_destination_character_references.md index 9f1daa9f26..acc8c3f361 100644 --- a/test/specs/new/link_destination_character_references.md +++ b/test/specs/new/link_destination_character_references.md @@ -1,16 +1,36 @@ --- renderExact: true --- +https://example.com/?x=1<2 + +https://example.com/?y=1&2 + +https://example.com/?a=1&b=2 + -https://example.com/?x=1<2 + + + + +[https://example.com/?x=1<2](https://example.com/?x=1<2) + +[https://example.com/?y=1&2](https://example.com/?y=1&2) + +[https://example.com/?a=1&b=2](https://example.com/?a=1&b=2) + +[https://example.com/?x=1<2][link1] + +[https://example.com/?y=1&2][link2] -[t](https://example.com/?a=1&b=2) +[https://example.com/?a=1&b=2][link3] -[example](http://example.com?foo=1&bar=2) +![https://example.com/?x=1<2](https://example.com/?x=1<2) -![i](http://example.com?foo=1&bar=2) +![https://example.com/?y=1&2](https://example.com/?y=1&2) -[t](http://example.com?a=1<2) +![https://example.com/?a=1&b=2](https://example.com/?a=1&b=2) - +[link1]: https://example.com/?x=1<2 +[link2]: https://example.com/?y=1&2 +[link3]: https://example.com/?a=1&b=2 From 47a0ca37ba575def97bbe6d26cb9da32f9a84ff1 Mon Sep 17 00:00:00 2001 From: mck09 Date: Wed, 19 Aug 2026 05:43:08 +0200 Subject: [PATCH 6/6] test: note which cases do not match CommonMark --- test/specs/new/link_destination_character_references.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/specs/new/link_destination_character_references.md b/test/specs/new/link_destination_character_references.md index acc8c3f361..1bceff43f6 100644 --- a/test/specs/new/link_destination_character_references.md +++ b/test/specs/new/link_destination_character_references.md @@ -1,4 +1,13 @@ --- +# Character references resolve in a link destination but not in an autolink, so +# the same query string has to be escaped differently depending on where it is +# written. +# +# The `<` inline links, reference links and images below do not match +# CommonMark. It resolves the reference and percent-encodes the result, giving +# `?x=1%3C2`, where marked keeps `?x=1<2`. That difference comes from URL +# encoding rather than from this escaping, and it is the same on `master`. +# Everything else here matches CommonMark. renderExact: true --- https://example.com/?x=1<2