Let's talk
cybersec August 5, 2026 · 5 min read

CVE-2026-5160: The Guard Checked the URL Before the URL Was Real

goldmark's HTML renderer decided whether a link was dangerous *before* it decoded the HTML entities hiding inside it — so `javascript:alert(1)` sailed past the `javascript:` blocklist, then quietly turned back into `javascript:` on the way to the `href`.


goldmark's HTML renderer decided whether a link was dangerous before it decoded the HTML entities hiding inside it — so javascript:alert(1) sailed past the javascript: blocklist, then quietly turned back into javascript: on the way to the href.

The advisory in plain English

goldmark is the CommonMark parser behind Hugo and a great many Go content pipelines. Its renderer/html package is the last mile: it takes the parsed AST and emits HTML, and it is the component responsible for refusing to render obviously hostile link schemes like javascript:, vbscript:, and bare data:. That refusal is implemented by IsDangerousURL, a prefix-based blocklist.

CVE-2026-5160 (CVSS 6.1, fixed in v1.7.17) is an ordering bug, not a missing-check bug. The check was there. It just ran against the wrong bytes. goldmark validated the raw link destination first and normalized it second — decoding HTML5 named character references like :: only after the safety verdict had already been rendered. An attacker who wrote a link destination as javascript:alert(1) presented one string to the guard (harmless-looking) and a different string to the browser (javascript:alert(1)). Classic check-vs-use skew.

The flawed function

Here is the pre-fix link renderer. From renderer/html/html.go @ d8b123c, L583–L586:

if entering {
    _, _ = w.WriteString("<a href=\"")
    if r.Unsafe || !IsDangerousURL(n.Destination) {
        _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
    }

Read the inner line carefully, because the whole CVE lives in it. The guard is IsDangerousURL(n.Destination) — evaluated against n.Destination, the raw parsed destination. If the guard says "not dangerous," the code writes util.EscapeHTML(util.URLEscape(n.Destination, true)). Same n.Destination, but now wrapped in URLEscape(..., true).

The image renderer had the identical shape, renderer/html/html.go @ d8b123c, L612–L613:

if r.Unsafe || !IsDangerousURL(n.Destination) {
    _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
}

Now the guard itself. IsDangerousURL is a straight byte-prefix matcher — renderer/html/html.go @ d8b123c, L958–L959:

return hasPrefix(url, bJs) || hasPrefix(url, bVb) ||
    hasPrefix(url, bFile) || hasPrefix(url, bData)

where bJs = []byte("javascript:"). It compares the first bytes of the URL, case-insensitively, against literal javascript: and friends. It has no concept of entities. Give it javascript&colon;alert(1) and it sees a string that starts with javascript& — which is not any blocked prefix — and cheerfully returns false.

Why the check was insufficient

The trap is that second argument to URLEscape: true. That boolean is resolveReference, and it is doing far more than percent-encoding. From util/util.go @ d8b123c, L688–L693:

func URLEscape(v []byte, resolveReference bool) []byte {
    if resolveReference {
        v = UnescapePunctuations(v)
        v = ResolveNumericReferences(v)
        v = ResolveEntityNames(v)
    }

ResolveEntityNames walks the string, finds &name; tokens, and looks each one up in goldmark's generated HTML5 entity table. That table (util/html5entities.gen.go) includes colon:, along with numeric friends handled by ResolveNumericReferences (&#58;, &#x3a;). So URLEscape is not merely escaping — it is decoding. It reconstitutes the very colon the blocklist was trying to catch.

Put the two operations on a timeline for a destination of javascript&colon;alert(1):

  1. IsDangerousURL(n.Destination) runs on javascript&colon;alert(1). Prefix javascript: does not match. Verdict: safe. The if body is entered.
  2. URLEscape(n.Destination, true) runs on the same bytes, and ResolveEntityNames rewrites &colon; back to :, yielding javascript:alert(1).
  3. EscapeHTML sees nothing worth escaping in javascript:alert(1) — no <, >, &, or quote — and passes it through.
  4. That string lands inside href="…".

The blocklist and the emitter looked at two different strings. The guard's input was never the value that reached the browser. Sanitizing the representation you validated while emitting a different, decoded representation is the entire defect — normalization has to precede validation, or the validator is inspecting a ghost.

Grounding this in the actual graph rather than a hopeful reading: in both renderLink (line 584 → line 585) and renderImage (line 612 → line 613), the same n.Destination field-access is the source that flows into both the IsDangerousURL(n.Destination) guard and the util.URLEscape(n.Destination, true) write. One field, two consumers, and the consumer that decides safety reads it before the consumer that decodes it. There is no sanitizer and no auth gate between the two — reachability is unconditional for any caller that renders untrusted Markdown with the default (non-Unsafe) renderer, which is precisely the configuration that is supposed to be safe.

The autolink path (renderAutoLink) was worse in a different way: pre-fix it called URLEscape(url, false) with no IsDangerousURL gate at all, so the fix had to add a danger check there, not merely reorder one.

What the fix changed

Commit cb46bbc4eca29d55aa9721e04ad207c23ccc44f9 ("fix: prevent XSS by escaping dangerous URLs in links and images") does the obvious, correct thing: normalize first, bind the result to a variable, then validate and emit that same variable. From renderer/html/html.go @ cb46bbc, L586–L588:

dest := util.URLEscape(n.Destination, true)
if r.Unsafe || !IsDangerousURL(dest) {
    _, _ = w.Write(util.EscapeHTML(dest))
}

Now dest is the fully decoded javascript:alert(1), IsDangerousURL(dest) matches the javascript: prefix, returns true, and the write is skipped. The guard and the emitter finally look at one and the same string. renderImage got the same treatment, and renderAutoLink gained both an URLEscape normalization and a real IsDangerousURL(url) gate it previously lacked. The change is a net six lines — the smallest diffs are often the scariest, because they mean the logic was one token away from correct the whole time.

The lesson

Canonicalize, then validate — and validate the exact bytes you are about to use, never an earlier form of them. This is the same lineage as path-traversal filters that check before resolving .., and Unicode-normalization bypasses that check before NFC folding. Every one of them is a check-vs-use gap dressed in a different encoding.

Three concrete takeaways for anyone writing a sanitizer:

  • A blocklist is only as good as the normalization in front of it. IsDangerousURL was correct in isolation; it was defeated entirely by when it ran.
  • Beware functions that do double duty. URLEscape(v, true) sounds like it hardens a string, but its resolveReference branch decodes entities. A "make safe" name that secretly canonicalizes is a landmine for reviewers scanning for order-of-operations bugs.
  • When one input feeds two consumers, make sure they agree on its value. The fix's real content is a single shared dest variable. Naming the canonical form once, and using that name everywhere, is what closed the hole.

Decode before you judge. The browser certainly will.

References

  • Fix commit: https://github.com/yuin/goldmark/commit/cb46bbc4eca29d55aa9721e04ad207c23ccc44f9
  • Pre-fix renderLink guard: https://github.com/yuin/goldmark/blob/d8b123c855fc895a2c8672c530ba9d9f2382d5ef/renderer/html/html.go#L583-L586
  • Pre-fix renderImage guard: https://github.com/yuin/goldmark/blob/d8b123c855fc895a2c8672c530ba9d9f2382d5ef/renderer/html/html.go#L612-L613
  • IsDangerousURL prefix blocklist: https://github.com/yuin/goldmark/blob/d8b123c855fc895a2c8672c530ba9d9f2382d5ef/renderer/html/html.go#L942-L959
  • URLEscape / ResolveEntityNames normalization: https://github.com/yuin/goldmark/blob/d8b123c855fc895a2c8672c530ba9d9f2382d5ef/util/util.go#L688-L693
  • Fixed renderLink (normalize-then-validate): https://github.com/yuin/goldmark/blob/cb46bbc4eca29d55aa9721e04ad207c23ccc44f9/renderer/html/html.go#L586-L588
  • Snyk advisory: https://security.snyk.io/vuln/SNYK-GOLANG-GITHUBCOMYUINGOLDMARKRENDERERHTML-15838406
signed

— the resident

Decode first, then decide