FlawOpen

flawopen.com/xss/Go

● CWE-79 · High
FlawOpen Security Research

Go XSS: Use html/template and Sanitize Rich Text

text/template, HTML fmt.Fprintf, and template.HTML can bypass escaping. Use html/template and bluemonday (CWE-79).

💡 Plain English Explainer (ELI5)

Think of a form printer that changes its safety stencil for the slot it is filling. Go html/template checks whether a value lands in HTML text, an attribute, a URL, JavaScript, or CSS and escapes for that context; text/template just prints it. Marking input as template.HTML says it is already trusted and skips that protection. Keep HTML output in html/template, and sanitize rich text before treating it as trusted.

Core Concepts & Subsystem Terms

html/template vs text/template
The two packages share an API, so swapping the import compiles cleanly. html/template escapes each value for the context it lands in: HTML text, attribute, URL, JavaScript or CSS. text/template escapes nothing, so {{.}} inserts raw markup. Use html/template for values rendered into an HTML response; use text/template only when the destination is genuinely plain text. Use text/template for non-HTML output such as configuration, plain-text email, or code generation. Do not treat html/template as a no-op for plain text: it may encode characters such as &. Choose the package based on the output format.
template.HTML / JS / URL
Typed strings such as template.HTML, template.JS and template.URL tell html/template the value is already safe and must not be escaped. Converting user input to one of them, as in template.HTML(r.FormValue("bio")), disables the protection for that value.
fmt.Fprintf to http.ResponseWriter
Writing HTML directly with fmt.Fprintf, io.WriteString or w.Write bypasses templates entirely. net/http also sniffs the Content-Type when none is set, so a response starting with markup is served as text/html.
#ZgotmplZ
When html/template sees an unsafe value in a URL attribute, such as javascript:alert(1) in href, it replaces it with #ZgotmplZ. Seeing that string in output means the filter caught something; wrapping the value in template.URL to make it go away reopens the hole.
bluemonday
An allowlist HTML sanitizer for Go. bluemonday.UGCPolicy() keeps formatting, links and images but strips scripts, event handlers and javascript: URLs. Sanitize first, then convert the result to template.HTML.

Framework mappings

Step-by-Step Attack Flow

Step 1

Input arrives in a request

A search link carries q, a greeting page reads name, and a profile form posts bio. A victim can be sent a crafted link, or a stored bio is shown to every visitor.

Step 2

The value escapes escaping

text/template inserts it raw, fmt.Fprintf writes it straight into the body, or template.HTML marks it trusted. The benign test value <img src=x onerror=alert(1)> reaches the response unchanged.

Step 3

The browser parses it as markup

The page contains a real <img> element. If the image resource fails to load, onerror runs alert(1).

Step 4

The script runs as the site

Go handlers should keep untrusted values in html/template data positions, where contextual escaping applies. text/template, direct fmt.Fprintf HTML output, and template.HTML bypass that protection.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// handlers.go: each handler below lets request text reach the page as HTML
package web

import (
	"fmt"
	"html/template"
	"net/http"
	texttemplate "text/template"
)

// text/template has the same API but escapes nothing
var results = texttemplate.Must(texttemplate.New("r").Parse(`<h1>Results for {{.}}</h1>`))

func Search(w http.ResponseWriter, r *http.Request) {
	results.Execute(w, r.URL.Query().Get("q"))
}

// Fprintf writes the name straight into the HTML body
func Greet(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "<p>Hello, %s</p>", r.URL.Query().Get("name"))
}

// template.HTML tells html/template not to escape the bio
var profile = template.Must(template.New("p").Parse(`<div class="bio">{{.Bio}}</div>`))

func Profile(w http.ResponseWriter, r *http.Request) {
	profile.Execute(w, struct{ Bio template.HTML }{template.HTML(r.FormValue("bio"))})
}
HARDENED SECURE PATCH
// handlers.go: html/template escapes by context; rich text is sanitized
package web

import (
	"html/template"
	"net/http"

	"github.com/microcosm-cc/bluemonday"
)

// html/template escapes the value for the HTML text context
var results = template.Must(template.New("r").Parse(`<h1>Results for {{.}}</h1>`))

func Search(w http.ResponseWriter, r *http.Request) {
	results.Execute(w, r.URL.Query().Get("q"))
}

// a template instead of Fprintf, with an explicit Content-Type
var greeting = template.Must(template.New("g").Parse(`<p>Hello, {{.}}</p>`))

func Greet(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	greeting.Execute(w, r.URL.Query().Get("name"))
}

// bluemonday strips scripts before the value is trusted
var profile = template.Must(template.New("p").Parse(`<div class="bio">{{.Bio}}</div>`))
var ugc = bluemonday.UGCPolicy()

func Profile(w http.ResponseWriter, r *http.Request) {
	clean := ugc.Sanitize(r.FormValue("bio"))
	profile.Execute(w, struct{ Bio template.HTML }{template.HTML(clean)})
}

Engineering & System Hardening Checklist

Sources