XSS in Go: How You Accidentally Create It and How to Kill It
Go has a reputation for being “safe by default” when you use html/template.
That’s partly true.
You can still absolutely ship XSS bugs in Go if you:
- Bypass templates with
fmt.Fprintforw.Write - Use
text/templateinstead ofhtml/template - Abuse
template.HTML/template.JS - Build URLs or inline JS by hand
This guide walks through concrete patterns that cause XSS in Go and how to avoid them.
1. Quick mental model: XSS in Go
XSS is when untrusted data (user input, DB content) ends up in a browser in a context where it’s treated as code, not data:
- Inside raw HTML:
Hello <script>alert(1)</script> - Inside attributes:
src="javascript:alert(1)" - Inside JS:
var name = "</script><script>alert(1)</script>"
In Go terms:
- Anything you write to
http.ResponseWriterultimately becomes HTML/JS in a browser. - If you just interpolate strings, Go won’t escape it unless you use
html/template.
Example:
func handler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
fmt.Fprintf(w, "<h1>Hello %s!</h1>", name) // XSS-prone
}
If name is <script>alert(1)</script>, the browser will run it.
2. Go’s main defense: html/template (and its limits)
Go ships two template packages:
text/template– no HTML awareness, no auto-escapinghtml/template– context-aware HTML escaping
You want html/template for all HTML views.
2.1 Safe-ish version with html/template
package main
import (
"html/template"
"net/http"
)
var tmpl = template.Must(template.New("hello").Parse(`
<!doctype html>
<html>
<body>
<h1>Hello {{.Name}}!</h1>
</body>
</html>
`))
type ViewData struct {
Name string
}
func handler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
data := ViewData{Name: name}
_ = tmpl.Execute(w, data) // .Name will be escaped
}
If Name contains <script>alert(1)</script>, html/template will render:
<h1>Hello <script>alert(1)</script>!</h1>
Script doesn’t run. Good.
2.2 text/template vs html/template
This is bad:
import "text/template" // ❌ not html/template
text/template does no HTML escaping; it just substitutes strings.
Use:
import "html/template" // ✅
2.3 Dangerous escape hatches: template.HTML, template.JS, template.URL
html/template has “typed” values:
template.HTML– raw HTML, trustedtemplate.JS– raw JStemplate.URL– raw URL
Passing user input into these is basically saying:
“I promise this is safe, please don’t escape it.”
So this is dangerous:
type ViewData struct {
Content template.HTML
}
func handler(w http.ResponseWriter, r *http.Request) {
// DO NOT DO THIS
userHTML := r.FormValue("content")
data := ViewData{
Content: template.HTML(userHTML), // ❌ trusted blindly
}
_ = tmpl.Execute(w, data)
}
If userHTML is <script>alert(1)</script>, it will run as script.
Use template.HTML only on output that has already been sanitized or generated by you, never on raw user input.
3. Classic XSS patterns in Go handlers (and fixes)
3.1 Direct string interpolation into HTML
Bad:
func greet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
// Direct interpolation into HTML
fmt.Fprintf(w, "<h1>Hello %s!</h1>", name) // ❌ XSS
}
Good:
var greetTmpl = template.Must(template.New("greet").Parse(`
<h1>Hello {{.}}</h1>
`))
func greet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
_ = greetTmpl.Execute(w, name) // ✅ escaped
}
3.2 Using text/template instead of html/template
Bad:
import "text/template"
var page = template.Must(template.New("page").Parse(`
<p>{{.Comment}}</p>
`))
// .Comment is not escaped for HTML
Good:
import "html/template"
var page = template.Must(template.New("page").Parse(`
<p>{{.Comment}}</p>
`))
// .Comment will be HTML-escaped
3.3 Marking input as template.HTML
Sometimes you want “rich text” (e.g., WYSIWYG editor). The temptation:
Bad:
type ViewData struct {
Content template.HTML
}
func viewPost(w http.ResponseWriter, r *http.Request) {
postHTML := r.FormValue("content") // or from DB
data := ViewData{
Content: template.HTML(postHTML), // ❌ XSS if postHTML has script tags
}
_ = tmpl.Execute(w, data)
}
Better: sanitize before trusting
Use a sanitizer library once (example API):
import "github.com/microcosm-cc/bluemonday"
var policy = bluemonday.UGCPolicy() // allow basic formatting, block scripts
func sanitizeHTML(raw string) template.HTML {
cleaned := policy.Sanitize(raw)
return template.HTML(cleaned) // sanitized before trusting
}
Then:
type ViewData struct {
Content template.HTML
}
func viewPost(w http.ResponseWriter, r *http.Request) {
raw := r.FormValue("content")
safe := sanitizeHTML(raw)
data := ViewData{Content: safe}
_ = tmpl.Execute(w, data)
}
Key idea: only sanitizeHTML is allowed to produce template.HTML.
3.4 Unsafe link / URL rendering
Attackers love javascript: URLs.
Bad:
type LinkData struct {
URL string
}
var linkTmpl = template.Must(template.New("link").Parse(`
<a href="{{.URL}}">click here</a>
`))
func handler(w http.ResponseWriter, r *http.Request) {
rawURL := r.URL.Query().Get("next") // user-controlled
data := LinkData{URL: rawURL} // ❌ could be "javascript:alert(1)"
_ = linkTmpl.Execute(w, data)
}
Even with escaping, href="javascript:alert(1)" is dangerous.
You need to validate scheme and maybe domain.
Good:
import "net/url"
func safeURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "" {
return "", fmt.Errorf("disallowed scheme")
}
return u.String(), nil
}
func handler(w http.ResponseWriter, r *http.Request) {
rawURL := r.URL.Query().Get("next")
next, err := safeURL(rawURL)
if err != nil {
next = "/" // fallback
}
data := LinkData{URL: next} // ✅ only http/https or relative
_ = linkTmpl.Execute(w, data)
}
3.5 Inline JS and event handlers
Putting untrusted data inside <script> or event handlers is fragile.
Bad:
var tmplJS = template.Must(template.New("page").Parse(`
<script>
var username = "{{.Name}}"; // ❌ JS context, escaping is trickier
</script>
`))
If .Name contains quotes and script tags, it can break out.
Better:
- Avoid inline JS where possible.
- Pass data via
data-*attributes and let JS read viadataset, or JSON-encode safely.
Safer pattern:
var tmplData = template.Must(template.New("page").Parse(`
<div id="user" data-name="{{.Name}}"></div>
<script src="/static/app.js"></script>
`))
Then in app.js:
const el = document.getElementById('user');
const name = el.dataset.name; // treated as data, not code
4. Contexts: HTML vs attributes vs JS vs URL
html/template is context-aware, but you still need to think about context.
- HTML body:
{{.}}in<p>{{.}}</p>→ good, auto-escaped. - Attribute values:
src="{{.}}oralt="{{.}}"→ mostly OK for text, but you need scheme/domain checks for URLs. - JS context:
var x = "{{.}}";→ brittle; escape rules differ. - Event handlers:
onclick="{{.}}→ don’t put untrusted stuff there.
Rule of thumb:
If untrusted data controls markup, JS, or URLs, you need more than just auto-escaping.
5. JSON APIs, SPAs, and XSS
Most Go apps today are JSON APIs with JS/React frontends.
XSS usually manifests in the frontend (DOM innerHTML, dangerouslySetInnerHTML in React, etc.), but your Go backend:
- Defines which fields exist.
- Decides whether those are “plain text” vs “HTML”.
- Decides whether to sanitize before storing/returning.
Backend responsibilities:
Treat everything from clients as untrusted.
For “rich text” fields:
- Either constrain to plain text (no HTML at all), or
- Sanitize on the server and store a safe HTML version.
Be explicit in API contracts:
comment_textis plain text.body_htmlis sanitized HTML you produced.
Example:
type Comment struct {
ID int64 `json:"id"`
Author string `json:"author"`
Message string `json:"message"` // plain text
}
// When rendering HTML later, you rely on html/template to escape Message.
If you ever add MessageHTML, make sure you control how it’s created.
6. Safer Go templating patterns
A few patterns to make life easier.
6.1 Central render helper
type TemplateStore struct {
tmpl *template.Template
}
func NewTemplateStore(pattern string) (*TemplateStore, error) {
t, err := template.ParseGlob(pattern) // uses html/template
if err != nil {
return nil, err
}
return &TemplateStore{tmpl: t}, nil
}
func (s *TemplateStore) Render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
All handlers go through this, so you don’t “accidentally” bypass templates.
6.2 Avoid template.HTML / template.JS except in one place
- Define one small package or function that returns
template.HTMLonly after sanitizing. - For JS, consider avoiding
template.JSentirely; pass data via attributes/JSON and let JS parse it.
7. Handling rich text in Go (markdown, WYSIWYG)
You’ll eventually need things like bios, comments, blog posts.
Options:
Plain text only
- Store plain text.
- Escape in templates with
{{.}}. - Easiest, safest.
Markdown
Store the original markdown.
On render:
- Convert markdown → HTML.
- Sanitize HTML.
- Then mark as
template.HTML.
Example pipeline (simplified):
func renderMarkdown(input string) template.HTML {
// 1. markdown to HTML (pseudo-code, depends on library)
html := markdownToHTML(input)
// 2. sanitize HTML
cleaned := policy.Sanitize(html)
// 3. mark as safe
return template.HTML(cleaned)
}
Your templates use:
{{.BodyHTML}} <!-- where BodyHTML is template.HTML returned by renderMarkdown -->
Don’t skip the sanitizer step.
8. Defense in depth: security headers & CSP
Even if you escape output correctly, it helps to have browser-side guardrails.
8.1 Simple security middleware in Go
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Basic hardening
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// CSP: adjust to your app
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';")
next.ServeHTTP(w, r)
})
}
Attach it:
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
http.ListenAndServe(":8080", securityHeaders(mux))
CSP can:
- Block inline scripts (if you configure
script-srcaccordingly). - Restrict script sources to your domains.
- Limit damage if XSS slips in.
It’s not a substitute for escaping/sanitizing, but it’s a strong backup.
9. Testing for XSS in Go apps
You don’t need a full scanner to catch basic XSS.
9.1 Simple test for encoding
Write unit tests around your template helpers, not just handlers.
func TestGreetingEscapesHTML(t *testing.T) {
var buf bytes.Buffer
data := struct{ Name string }{"<script>alert(1)</script>"}
if err := greetTmpl.Execute(&buf, data); err != nil {
t.Fatal(err)
}
out := buf.String()
if strings.Contains(out, "<script>") {
t.Fatalf("expected script tags to be escaped, got: %s", out)
}
if !strings.Contains(out, "<script>") {
t.Fatalf("expected escaped script tags, got: %s", out)
}
}
9.2 Manual spot checks
For inputs like:
<script>alert(1)</script>"><img src=x onerror=alert(1)>javascript:alert(1)
Try:
Putting them into form fields / query params.
Checking whether the page:
- shows them as text, or
- actually executes them.
10. XSS-safe Go habits (checklist)
You don’t need to memorize everything. Use this as a quick checklist:
XSS-Safe Go Checklist
- All HTML responses are rendered via html/template, not manual fmt.Fprintf with untrusted data.
- We do not use text/template for HTML views.
- We never pass raw user input into template.HTML / template.JS / template.URL.
- Any
template.HTMLvalues are produced by a small, well-reviewed sanitizer function. - We validate URLs (scheme, domain) before putting them in href/src attributes.
- We treat all user-generated content as hostile: - Plain text fields are escaped via {{.}}. - Rich text goes through markdown → sanitize → template.HTML.
- We avoid embedding untrusted data inside inline blocks or event handlers.
- We set basic security headers (CSP, X-Content-Type-Options, etc.) via middleware.
- We have tests that ensure classic XSS payloads end up escaped, not executed.
If you follow these habits, you’ll avoid most XSS issues in Go apps.
The core idea is simple:
Let Go’s
html/templatedo the escaping, only bypass it in one or two carefully controlled places, and treat all user input as hostile until proven otherwise.