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.Fprintf or w.Write
  • Use text/template instead of html/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.ResponseWriter ultimately 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-escaping
  • html/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 &lt;script&gt;alert(1)&lt;/script&gt;!</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, trusted
  • template.JS – raw JS
  • template.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.


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 via dataset, 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="{{.}} or alt="{{.}}" → 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_text is plain text.
    • body_html is 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.HTML only after sanitizing.
  • For JS, consider avoiding template.JS entirely; 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:

  1. Plain text only

    • Store plain text.
    • Escape in templates with {{.}}.
    • Easiest, safest.
  2. 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-src accordingly).
  • 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, "&lt;script&gt;") {
        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.HTML values 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/template do the escaping, only bypass it in one or two carefully controlled places, and treat all user input as hostile until proven otherwise.