Render Markdown in Go With Goldmark Without XSS Holes
Render Markdown in Go with goldmark safely: what defaults block, what WithUnsafe lets through, and how to sanitize output with bluemonday for user content.

Markdown looks like text, so it is easy to forget that it is a way of writing HTML. A renderer that turns [click](javascript:alert(1)) into a working link, or passes an <img onerror=...> through untouched, has turned a comment box into a script injection point. Whether your Go service is exposed depends on three choices: which renderer options you pass, whether the author is trusted, and whether you sanitize after rendering. The defaults of goldmark are better than most people expect, and the one flag that undoes them is easy to enable without noticing.
Tested with Go 1.25.6, goldmark v1.8.6 and bluemonday v1.0.27, macOS on Apple Silicon (arm64)
This article runs a set of hostile inputs through goldmark in three configurations and reads the output, so the claims below are observations and not folklore. It continues the Markdown blog project, where the authors are trusted, and it belongs to the web cluster that ends in a production HTTP server. The surrounding output rules, escaping and Content-Security-Policy, are in the pastebin project. Everything ran on Go 1.25.6 with goldmark v1.8.6 and bluemonday v1.0.27.
Three pipelines, ten inputs
The program renders each input three ways: with goldmark's defaults, with html.WithUnsafe() enabled, and with the unsafe output passed through bluemonday's user-generated-content policy.
package main
import (
"bytes"
"fmt"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
func render(md goldmark.Markdown, src string) string {
var buf bytes.Buffer
if err := md.Convert([]byte(src), &buf); err != nil {
return "error: " + err.Error()
}
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\n", " "))
}
func main() {
safe := goldmark.New(goldmark.WithExtensions(extension.GFM, extension.Linkify))
unsafe := goldmark.New(goldmark.WithExtensions(extension.GFM, extension.Linkify), goldmark.WithRendererOptions(html.WithUnsafe()))
policy := bluemonday.UGCPolicy().RequireNoFollowOnLinks(true).AddTargetBlankToFullyQualifiedLinks(true)
inputs := []struct{ label, md string }{
{"benign link", `[docs](https://go.dev/doc "Go docs")`},
{"script tag", `<script>alert(1)</script>`},
{"inline handler", `<img src=x onerror=alert(1)>`},
{"javascript link", `[click](javascript:alert(1))`},
{"mixed case scheme", `[click](JaVaScRiPt:alert(1))`},
{"data image", ``},
{"raw anchor", `<a href="javascript:alert(1)">x</a>`},
{"iframe", `<iframe src="https://evil.example"></iframe>`},
{"autolink", `<javascript:alert(1)>`},
{"bare url", `see https://example.org/path?a=1&b=2`},
}
for _, in := range inputs {
fmt.Printf("%s\n input: %s\n", in.label, in.md)
fmt.Printf(" default: %s\n", render(safe, in.md))
u := render(unsafe, in.md)
fmt.Printf(" WithUnsafe: %s\n", u)
fmt.Printf(" + sanitize: %s\n", strings.TrimSpace(strings.ReplaceAll(policy.Sanitize(u), "\n", " ")))
}
}benign link
input: [docs](https://go.dev/doc "Go docs")
default: <p><a href="https://go.dev/doc" title="Go docs">docs</a></p>
WithUnsafe: <p><a href="https://go.dev/doc" title="Go docs">docs</a></p>
+ sanitize: <p><a href="https://go.dev/doc" title="Go docs" rel="nofollow noopener" target="_blank">docs</a></p>
script tag
input: <script>alert(1)</script>
default: <!-- raw HTML omitted -->
WithUnsafe: <script>alert(1)</script>
+ sanitize:
inline handler
input: <img src=x onerror=alert(1)>
default: <!-- raw HTML omitted -->
WithUnsafe: <img src=x onerror=alert(1)>
+ sanitize: <img src="x">
javascript link
input: [click](javascript:alert(1))
default: <p><a href="">click</a></p>
WithUnsafe: <p><a href="javascript:alert(1)">click</a></p>
+ sanitize: <p>click</p>
mixed case scheme
input: [click](JaVaScRiPt:alert(1))
default: <p><a href="">click</a></p>
WithUnsafe: <p><a href="JaVaScRiPt:alert(1)">click</a></p>
+ sanitize: <p>click</p>
data image
input: 
default: <p><img src="" alt="x"></p>
WithUnsafe: <p><img src="data:text/html;base64,PHNjcmlwdD4=" alt="x"></p>
+ sanitize: <p><img alt="x"></p>
raw anchor
input: <a href="javascript:alert(1)">x</a>
default: <p><!-- raw HTML omitted -->x<!-- raw HTML omitted --></p>
WithUnsafe: <p><a href="javascript:alert(1)">x</a></p>
+ sanitize: <p>x</p>
iframe
input: <iframe src="https://evil.example"></iframe>
default: <!-- raw HTML omitted -->
WithUnsafe: <iframe src="https://evil.example"></iframe>
+ sanitize:
autolink
input: <javascript:alert(1)>
default: <p><a href="">javascript:alert(1)</a></p>
WithUnsafe: <p><a href="javascript:alert(1)">javascript:alert(1)</a></p>
+ sanitize: <p>javascript:alert(1)</p>
bare url
input: see https://example.org/path?a=1&b=2
default: <p>see <a href="https://example.org/path?a=1&b=2">https://example.org/path?a=1&b=2</a></p>
WithUnsafe: <p>see <a href="https://example.org/path?a=1&b=2">https://example.org/path?a=1&b=2</a></p>
+ sanitize: <p>see <a href="https://example.org/path?a=1&b=2" rel="nofollow noopener" target="_blank">https://example.org/path?a=1&b=2</a></p>What the defaults do
Read the default lines first. Every raw HTML payload (the script tag, the onerror image, the iframe, the raw anchor) became <!-- raw HTML omitted -->. Goldmark does not try to decide which HTML is dangerous; it refuses all of it, which is the only filter that does not need a blocklist. The javascript: link survived as an anchor with an empty href, and so did the mixed-case JaVaScRiPt: version, which matters because scheme matching is case-insensitive in browsers and a filter that compares against lowercase text is a classic bypass. The data:text/html image lost its source, and the angle-bracket autolink <javascript:...> was neutralised the same way. The benign link and the bare URL came through as intended.
This is the behavior documented in goldmark's README: raw HTML is omitted and potentially dangerous URLs are filtered unless you ask otherwise. For content typed by your own team, that is usually all you need, and the cost of keeping it is zero.
What WithUnsafe changes
Now read the WithUnsafe lines. Every payload passed through verbatim: the script tag, the handler attribute, the iframe, and the javascript: URLs in links and images. The option exists for a good reason, since technical writing often needs a <details> block, a <kbd> tag or an embedded video. It is also the line that decides whether anyone who can submit Markdown can run script on your readers' browsers. So treat it as a statement about the author: enable it only where every author is someone you would give commit access to the repository, and never on comments, profiles, issue text or anything a visitor types.
Sanitizing after rendering
When you need raw HTML from people you do not fully trust, or when you do not want your safety to depend on a single renderer flag, sanitize the output. The + sanitize lines show bluemonday's UGCPolicy applied to the unsafe HTML: the script and iframe vanish entirely, the onerror attribute is stripped from the image, javascript: hrefs and data: image sources are removed, and what is left is plain text where a link used to be. The policy built above also adds rel="nofollow noopener" and target="_blank" to links, which is what you want for user content: it stops your site passing search credit to spammers and stops the opened page from reaching back through window.opener.
The order is the part to get right. Sanitize the HTML that comes out of the renderer, not the Markdown that goes in. Markdown has many ways to produce a link or an image, and a filter that works on text has to know all of them; bluemonday parses HTML and knows exactly what an element and an attribute are. It is also an allowlist, so a new HTML feature is blocked until you permit it.
A sanitizer is a policy, and policies need tuning. UGCPolicy allows common formatting, links and images but not class attributes, so a syntax highlighter's output loses its token classes. Allow them narrowly: policy.AllowAttrs("class").Matching(regexp.MustCompile("^(language-[a-z0-9]+|chroma|[a-z]{1,3})$")).OnElements("code", "pre", "span") keeps the highlighting without opening class for everything. Forbid remote images if tracking pixels matter to you (policy.AllowImages is part of the UGC policy; remove it or route images through your own proxy). Keep target="_blank" paired with rel="noopener", which bluemonday does for you here.
Where the holes are that Markdown does not cause
Most real injection bugs in Markdown systems are around the renderer, not in it.
template.HTMLon unrendered or user-controlled strings. Converting a string totemplate.HTMLturns offhtml/template's escaping. Do it exactly once, on the output of a safe pipeline, and nowhere else. A title field wrapped intemplate.HTML"because it has an ampersand" is the common mistake.- Titles and metadata. The post title, tags and alt text come from the same author, are not Markdown, and go into attributes and
<title>. Leave them as plain strings so the template escapes them for the right context. - Heading IDs and anchors. Auto-generated IDs from heading text are fine, but IDs built from user input and placed in
idorhrefattributes need the same scrutiny as any attribute. - Preview and email. The same HTML often goes into a mail client or a push notification, where the rules differ. Sanitize for the strictest destination.
- Stored output. If you cache rendered HTML in the database, you freeze the policy of the day. When you tighten the sanitizer, re-render, which means keeping the Markdown source as the record and the HTML as a cache.
Defence in depth belongs here too. A Content-Security-Policy that forbids inline scripts (script-src 'self' with no unsafe-inline) turns a sanitizer bug from a breach into a console error. It does not replace sanitizing, since not every attack is a script, but it is the second lock. MDN's CSP guide explains the directives, and the OWASP XSS prevention cheat sheet lists the encoding rules by context.
Testing the pipeline you ship
The table above is a demonstration. The version that protects you is a test that runs your real pipeline, with your real options, over a list of known payloads, and fails if any dangerous construct appears in the output. It also checks that one shared instance survives concurrent use, because rendering usually happens inside a handler.
package main
import (
"bytes"
"strings"
"sync"
"testing"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
var payloads = []string{
`<script>alert(1)</script>`,
`<img src=x onerror=alert(1)>`,
`[x](javascript:alert(1))`,
`[x](JaVaScRiPt:alert(1))`,
``,
`<a href="javascript:alert(1)">x</a>`,
`<iframe src="https://evil.example"></iframe>`,
`<svg onload=alert(1)>`,
`<style>@import url(https://evil.example/x.css)</style>`,
}
func assertClean(t *testing.T, name, out string) {
t.Helper()
low := strings.ToLower(out)
for _, bad := range []string{"<script", "javascript:", "onerror", "onload", "<iframe", "<svg", "<style"} {
if strings.Contains(low, bad) {
t.Errorf("%s: output contains %q: %s", name, bad, out)
}
}
}
func TestDefaultsAreSafe(t *testing.T) {
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
for _, p := range payloads {
var buf bytes.Buffer
md.Convert([]byte(p), &buf)
assertClean(t, "defaults", buf.String())
}
}
func TestUnsafePlusSanitizerIsSafe(t *testing.T) {
md := goldmark.New(goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(html.WithUnsafe()))
policy := bluemonday.UGCPolicy()
for _, p := range payloads {
var buf bytes.Buffer
md.Convert([]byte(p), &buf)
assertClean(t, "unsafe+sanitizer", policy.Sanitize(buf.String()))
}
}
func TestSharedInstanceIsSafeForConcurrentUse(t *testing.T) {
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, p := range payloads {
var buf bytes.Buffer
md.Convert([]byte(p), &buf)
assertClean(t, "concurrent", buf.String())
}
}()
}
wg.Wait()
}=== RUN TestDefaultsAreSafe
--- PASS: TestDefaultsAreSafe (0.00s)
=== RUN TestUnsafePlusSanitizerIsSafe
--- PASS: TestUnsafePlusSanitizerIsSafe (0.01s)
=== RUN TestSharedInstanceIsSafeForConcurrentUse
--- PASS: TestSharedInstanceIsSafeForConcurrentUse (0.01s)
PASS
ok example.test/lab 1.318sThe tests run with -race like every test in this series, and the concurrency one passed with 32 goroutines sharing a single goldmark.Markdown, so the habit of building it once at startup is also the safe one. Extend payloads every time you read about a new bypass; the OWASP and PortSwigger XSS lists are good sources, and the cost of each added string is one line. If a future refactor flips on WithUnsafe for the default pipeline, the first test fails and says why.
A decision rule
Trusted authors writing in a repository: defaults, no sanitizer, and the test above. Trusted authors who need raw HTML: WithUnsafe, and keep the sanitizer as a safety net anyway, since it costs microseconds. Anyone else: defaults at minimum, UGCPolicy on the output, a CSP, and links marked nofollow. The goldmark options are documented at pkg.go.dev/github.com/yuin/goldmark, and bluemonday's policy builder at pkg.go.dev/github.com/microcosm-cc/bluemonday.


