Skip to content
gohacks.dev
Web

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.

10 min read
Summarize in AI
Flat illustration of a shield with a lock, a key and a password field, with the Markdown logo.

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.

main.goGo
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", `![x](data:text/html;base64,PHNjcmlwdD4=)`},
		{"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", " ")))
	}
}
Text
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:      ![x](data:text/html;base64,PHNjcmlwdD4=)
   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&amp;b=2">https://example.org/path?a=1&amp;b=2</a></p>
   WithUnsafe: <p>see <a href="https://example.org/path?a=1&amp;b=2">https://example.org/path?a=1&amp;b=2</a></p>
   + sanitize: <p>see <a href="https://example.org/path?a=1&amp;b=2" rel="nofollow noopener" target="_blank">https://example.org/path?a=1&amp;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.HTML on unrendered or user-controlled strings. Converting a string to template.HTML turns off html/template's escaping. Do it exactly once, on the output of a safe pipeline, and nowhere else. A title field wrapped in template.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 id or href attributes 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.

md_test.goGo
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))`,
	`![x](data:text/html;base64,PHNjcmlwdD4=)`,
	`<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()
}
Text
=== RUN   TestDefaultsAreSafe
--- PASS: TestDefaultsAreSafe (0.00s)
=== RUN   TestUnsafePlusSanitizerIsSafe
--- PASS: TestUnsafePlusSanitizerIsSafe (0.01s)
=== RUN   TestSharedInstanceIsSafeForConcurrentUse
--- PASS: TestSharedInstanceIsSafeForConcurrentUse (0.01s)
PASS
ok  	example.test/lab	1.318s

The 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.