GitHub added CVE-2026-102941 to its advisory database on 16 October 2026. It's a crash in gomarkdown, a Go Markdown library, and it only happens when you turn on the library's link-sanitizing option. The bug is real, but the bigger story is what the database entry leaves out. Since July, the gomarkdown repository has published five security advisories, including a critical cross-site scripting bug in the default configuration. Four of the five have no CVE. On 16 October, none of those four were in GitHub's reviewed database, OSV or the Go vulnerability database.
This teardown is for Go teams that render Markdown from users, comments, tickets or LLM output. It covers the crash, the four advisories that scanners don't report, a reproduction that runs all five against two versions, and a script that checks a Go module for the fixes.
The short answer
Upgrade github.com/gomarkdown/markdown to a pseudo-version from 23 September 2026 or later. The latest on 16 October was v0.0.0-20261011113952-f83953680332. CVE-2026-102941 makes markdown.ToHTML panic on an empty link such as [x]() when the html.Safelink flag is on, and was fixed on 24 July. Four more advisories, published only in the gomarkdown repository and without CVEs, cover XSS through heading IDs and fenced code blocks in the default configuration, XSS through the table of contents, and two inputs that take quadratic time to parse. A June 2026 version has all five, and in our test govulncheck reported none of them.
The crash: a safety flag that panics
html.Safelink tells the renderer to drop links whose destination isn't a known-safe scheme or a relative path. To check relative paths, parser.IsSafeURL loops over the prefixes /, ./ and ../, and it sliced the URL to each prefix's length before checking that the URL was long enough:
linkPrefix := url[:nPath]
if nLink >= nPath && bytes.Equal(linkPrefix, path) {
An empty destination, . or .. is shorter than a prefix, so url[:nPath] panics with "slice bounds out of range". The fix, commit 61af6e1 by maintainer Krzysztof Kowalczyk on 24 July, moves the length check first. The advisory credits GitHub user arpitjain099, who notes the bug had been there since 2022.
How bad a panic is depends on where ToHTML runs. Go's net/http server recovers a panic in a handler, logs it and closes that one connection, so a web server keeps serving. A panic anywhere else, such as a background worker that renders queued comments, kills the whole process. The advisory's 7.5 score assumes the second case. Our reproduction below shows both.
The four advisories with no CVE
The repository's own security advisories page lists these, all fixed between July and September:
| Advisory | Published | Severity | Bug | Fixed in commit |
|---|---|---|---|---|
| GHSA-gc99-qr5c-98ff | 25 Jul | Critical | XSS in the default configuration: {#id} heading IDs and fenced code info strings were written into HTML attributes unescaped | 8435af3, 25 Jul |
| GHSA-85vw-wvf9-r522 | 24 Aug | Medium | A run of unmatched [ takes quadratic time to parse | 7e841db, 24 Aug |
| GHSA-g6w5-3rhc-c379 | 14 Sep | Medium | The July escaping missed the table of contents, figure captions and citations | df60b3d, 14 Sep |
| GHSA-h7w2-xgxh-p66q | 23 Sep | Medium | A run of *[ takes quadratic time, through a second bracket scan the August fix didn't cover | 06faf78, 23 Sep |
They credit uug4na, arpitjain099, 4n86rakam1 and NotAFlightRisk respectively.
The critical one needs no options. Heading IDs and fenced code are in the parser's default extensions, so markdown.ToHTML(input, nil, nil) turned # Hi {#x" onclick="alert(1)} into an <h1> with an onclick attribute. The fenced-code variant was worse for anyone who escaped input first: the parser decoded HTML entities in the info string before writing it out, so " became a real quote.
Two things keep these out of scanners. None has a CVE, and GitHub's reviewed database, which feeds Dependabot and OSV, only added the one that does. And gomarkdown publishes no version tags, so every version is a pseudo-version such as v0.0.0-20260614204949-e08cff860f76. The repository advisories describe affected ranges in that form, inconsistently. CVE-2026-102941's own repository entry lists every version up to the year 2099 as affected, with no patched version. GitHub's reviewed entry corrected that on 16 October.
Reproducing all five
This program runs the advisories' inputs through whatever gomarkdown version go.mod pins. It prints rendered HTML and timings, runs the crash through a real net/http server, and in goroutine mode runs it outside one.
// Runs the published gomarkdown advisory inputs against whatever version
// go.mod pins. Defender-side: it prints rendered HTML and timings only.
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
func render(in string, flags html.Flags) (out string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
r := html.NewRenderer(html.RendererOptions{Flags: flags})
return string(markdown.ToHTML([]byte(in), parser.New(), r)), nil
}
func show(name, in string, flags html.Flags) {
out, err := render(in, flags)
if err != nil {
fmt.Printf("%-24s %v\n", name, err)
return
}
fmt.Printf("%-24s %s\n", name, strings.TrimSpace(strings.ReplaceAll(out, "\n", " ")))
}
func timeIt(name, in string) {
start := time.Now()
markdown.ToHTML([]byte(in), nil, nil)
fmt.Printf("%-24s %d bytes in %v\n", name, len(in), time.Since(start).Round(time.Millisecond))
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "goroutine" {
// The same render outside net/http: nothing recovers the panic.
done := make(chan struct{})
go func() {
r := html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.Safelink})
markdown.ToHTML([]byte("[x]()"), nil, r)
close(done)
}()
<-done
fmt.Println("worker finished")
return
}
show("safelink empty link", "[x]()", html.CommonFlags|html.Safelink)
show("heading id", `# Hi {#x" onclick="alert(1)}`, html.CommonFlags)
show("fenced info entity", "```x"onclick="alert(1)\ncode\n```", html.CommonFlags)
show("javascript: link", "[x](javascript:alert(1))", html.CommonFlags)
show("toc heading id", "# Hi {#x\"onmouseover=\"alert(1)}", html.CommonFlags|html.TOC)
timeIt("unmatched [", strings.Repeat("[", 32000))
timeIt("emphasis *[", strings.Repeat("*[", 32000))
// net/http recovers a handler panic and closes that one connection.
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
r := html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.Safelink})
w.Write(markdown.ToHTML([]byte(req.URL.Query().Get("md")), nil, r))
}))
srv.Config.ErrorLog = log.New(firstLine{}, "", 0)
srv.Start()
defer srv.Close()
for _, md := range []string{"%5Bx%5D()", "hello"} {
resp, err := http.Get(srv.URL + "/?md=" + md)
if err != nil {
fmt.Printf("%-24s request failed: %v\n", "http "+md, err)
continue
}
resp.Body.Close()
fmt.Printf("%-24s status %d\n", "http "+md, resp.StatusCode)
}
}
// firstLine prints only the first line of the server's panic log.
type firstLine struct{}
func (firstLine) Write(p []byte) (int, error) {
line, _, _ := bytes.Cut(p, []byte("\n"))
fmt.Printf("%-24s %s\n", "server log", line)
return len(p), nil
}
On the version from 14 June 2026:
$ go run .
safelink empty link panic: runtime error: slice bounds out of range [:1] with capacity 0
heading id <h1 id="x" onclick="alert(1)">Hi</h1>
fenced info entity <pre><code class="language-x"onclick="alert(1)">code </code></pre>
javascript: link <p><a href="javascript:alert(1)">x</a></p>
toc heading id <nav> <ul> <li><a href="#x"onmouseover="alert(1)">Hi</a></li> </ul> </nav> <h1 id="x"onmouseover="alert(1)">Hi</h1>
unmatched [ 32000 bytes in 534ms
emphasis *[ 64000 bytes in 1.001s
server log http: panic serving 127.0.0.1:64522: runtime error: slice bounds out of range [:1] with capacity 0
http %5Bx%5D() request failed: Get "http://127.0.0.1:64521/?md=%5Bx%5D()": EOF
http hello status 200
$ go run . goroutine
panic: runtime error: slice bounds out of range [:1] with capacity 0
goroutine 20 [running]:
...
$ echo $?
1
On the version from 11 October 2026:
$ go run .
safelink empty link <p><tt>x</tt></p>
heading id <h1 id="x" onclick="alert(1)">Hi</h1>
fenced info entity <pre><code class="language-x"onclick="alert(1)">code </code></pre>
javascript: link <p><a href="javascript:alert(1)">x</a></p>
toc heading id <nav> <ul> <li><a href="#x"onmouseover="alert(1)">Hi</a></li> </ul> </nav> <h1 id="x"onmouseover="alert(1)">Hi</h1>
unmatched [ 32000 bytes in 1ms
emphasis *[ 64000 bytes in 13ms
http %5Bx%5D() status 200
http hello status 200
$ go run . goroutine
worker finished
$ echo $?
0
All four XSS inputs become escaped attributes. The quadratic inputs drop from about half a second and one second to a few milliseconds, and the gap grows with input size, since doubling the input quadruples the time on the old version. The crash becomes a link Safelink removes, and the background worker survives.
The javascript: line is the same on both versions. GHSA-gc99-qr5c-98ff lists it as a third issue, but the CVE-2026-102941 report describes default-configuration XSS as by design, and the latest version still renders the link. If the input is untrusted, turn on html.Safelink or sanitize the HTML output.
What scanners reported
We ran three checks against the 14 June version on 16 October:
| Source | gomarkdown findings |
|---|---|
| govulncheck v1.9.0 (database updated 8 October) | none |
| OSV query for that version | GHSA-cv23-7vc5-jfh7 only |
| GitHub reviewed advisory database | GHSA-cv23-7vc5-jfh7 only, added 16 October |
| gomarkdown repository advisories | five apply |
The Go vulnerability database's last gomarkdown entry is GO-2026-5208, for CVE-2026-40890 from April, which this version already fixes. That's why govulncheck is clean. It hadn't added CVE-2026-102941 when we checked.
Check your module
This script reads go.mod and your .go files. It parses the commit timestamp from gomarkdown's pseudo-version, compares it with each fix commit's timestamp, and lists what's missing and whether each item is in GitHub's reviewed database. It also reports whether the code uses html.Safelink, html.TOC, the Attributes extension, or the bluemonday sanitizer. It uses only the Python standard library.
#!/usr/bin/env python3
"""Check a Go module for gomarkdown advisories that scanners may not report.
Usage: python3 gomarkdown_check.py <module dir>
Reads go.mod and .go files only. gomarkdown publishes no version tags, so
every version is a pseudo-version, and this compares the commit timestamp in
it with the timestamp of each fix commit on the main branch.
"""
import pathlib, re, sys
MOD = "github.com/gomarkdown/markdown"
# (advisory, CVE or None, in GitHub's reviewed database, fix commit time UTC, fix commit, what)
FIXES = [
("GHSA-77fj-vx54-gvh7", "CVE-2026-40890", True, "20260411013819", "759bbc3", "out-of-bounds read in SmartypantsRenderer"),
("GHSA-cv23-7vc5-jfh7", "CVE-2026-102941", True, "20260724235538", "61af6e1", "panic on short link with html.Safelink"),
("GHSA-gc99-qr5c-98ff", None, False, "20260725000948", "8435af3", "XSS via heading IDs and fenced code info, default config"),
("GHSA-85vw-wvf9-r522", None, False, "20260824110314", "7e841db", "quadratic parse time on unmatched ["),
("GHSA-g6w5-3rhc-c379", None, False, "20260914122201", "df60b3d", "XSS via heading IDs in TOC, figures, citations"),
("GHSA-h7w2-xgxh-p66q", None, False, "20260923164715", "06faf78", "quadratic parse time on *[ runs"),
]
USES = {
"html.Safelink": "Safelink flag (reaches the IsSafeURL panic before the July fix)",
"IsSafeURL": "direct IsSafeURL call",
"html.TOC": "TOC flag (heading-ID XSS sink fixed in September)",
"parser.Attributes": "Attributes extension (README: unsafe for untrusted input)",
"bluemonday": "bluemonday sanitizer present",
}
def main():
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")
gomod = (root / "go.mod").read_text()
m = re.search(re.escape(MOD) + r"\s+(v\S+)", gomod)
if not m:
print(f"{MOD} not in go.mod"); return 0
ver = m.group(1)
ts = re.search(r"-(\d{14})-[0-9a-f]{12}$", ver)
print(f"{MOD} {ver}")
if not ts:
print(" not a pseudo-version; check it by hand"); return 1
missing = 0
for ghsa, cve, reviewed, fixed, commit, what in FIXES:
if ts.group(1) < fixed:
missing += 1
seen = "in GitHub reviewed DB" if reviewed else "repo advisory only"
print(f" MISSING {ghsa} ({cve or 'no CVE'}, {seen}): {what}; fixed in {commit}")
if not missing:
print(" ok: includes every fix listed here")
hits = {}
for f in root.rglob("*.go"):
if "vendor" in f.parts:
continue
text = f.read_text(errors="ignore")
for key in USES:
if key in text:
hits.setdefault(key, []).append(str(f.relative_to(root)))
for key, files in hits.items():
print(f" uses {USES[key]}: {', '.join(sorted(files)[:3])}")
return 1 if missing else 0
if __name__ == "__main__":
sys.exit(main())
On the reproduction module, pinned to the June version and then the October one:
$ python3 gomarkdown_check.py .
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
MISSING GHSA-cv23-7vc5-jfh7 (CVE-2026-102941, in GitHub reviewed DB): panic on short link with html.Safelink; fixed in 61af6e1
MISSING GHSA-gc99-qr5c-98ff (no CVE, repo advisory only): XSS via heading IDs and fenced code info, default config; fixed in 8435af3
MISSING GHSA-85vw-wvf9-r522 (no CVE, repo advisory only): quadratic parse time on unmatched [; fixed in 7e841db
MISSING GHSA-g6w5-3rhc-c379 (no CVE, repo advisory only): XSS via heading IDs in TOC, figures, citations; fixed in df60b3d
MISSING GHSA-h7w2-xgxh-p66q (no CVE, repo advisory only): quadratic parse time on *[ runs; fixed in 06faf78
uses Safelink flag (reaches the IsSafeURL panic before the July fix): main.go
uses TOC flag (heading-ID XSS sink fixed in September): main.go
$ echo $?
1
$ python3 gomarkdown_check.py .
github.com/gomarkdown/markdown v0.0.0-20261011113952-f83953680332
ok: includes every fix listed here
uses Safelink flag (reaches the IsSafeURL panic before the July fix): main.go
uses TOC flag (heading-ID XSS sink fixed in September): main.go
$ echo $?
0
The timestamp comparison works because gomarkdown's fixes landed on a single main branch. A fork or a replaced module needs checking by hand.
What to do
- Upgrade with
go get github.com/gomarkdown/markdown@latest, then confirm the pseudo-version ingo.modis from 23 September 2026 or later. - Treat rendered Markdown as untrusted HTML. Turn on
html.Safelink, after upgrading, and run the output through an HTML sanitizer such as bluemonday before it reaches a browser. - Recover panics in background renderers.
net/httpdoes it per request. A worker pool or queue consumer has to do it itself. - Cap input size before parsing. Both quadratic bugs needed tens of kilobytes, and the next one will too.
- Read the repository's advisories page for dependencies like this one, with no version tags and advisories without CVEs. For gomarkdown, it was the only source that listed all five.
We covered a JavaScript example of the same problem in our Showdown Markdown XSS guide, and how govulncheck's database works in scanning Go codebases with govulncheck.
The fair view
The maintainer fixed each of these, added regression tests with the July crash fix, and published advisories with clear explanations. The XSS issues were also partly a matter of expectation. gomarkdown has never claimed to sanitize its output, and its README now warns that the Attributes extensions are unsafe for untrusted input. Teams that already sanitized rendered HTML were largely protected from the XSS bugs, whatever version they ran.
But the gap between the repository and the databases is the lesson. A maintainer can publish a repository advisory without requesting a CVE, and when they do, the advisory reaches people who watch that repository and almost nobody else. Dependency scanners, OSV and the Go database all key on records that, for four of these five bugs, didn't exist. If a library matters to you and has no release process of its own, its security advisories page is part of your vulnerability feed.