Render HTML with html/template, never text/template: it escapes each value for where it lands: text, attribute or URL. Keep request data out of template.HTML, template.URL and the other trusted types, which switch that escaping off. A javascript: URL has nothing to escape, so link a website only when url.Parse gives an http or https scheme and a host.
- Find every import of text/template in code that writes HTML, every fmt.Fprintf, io.WriteString or w.Write of request data to a ResponseWriter, and every conversion of a non-constant value to template.HTML, HTMLAttr, JS, JSStr, CSS, Srcset or URL.
- Import html/template instead of text/template. The API is the same, so the templates and the Execute calls stay as they are, and every {{.Field}} is escaped for its context.
- Pass request and database values as plain strings and delete the trusted-type conversions. For rich text that has to keep some HTML, run it through an allowlist-based HTML sanitizer such as github.com/microcosm-cc/bluemonday before converting it, never a regular expression.
- For a URL in href or src, parse it with url.Parse and link it only when the scheme is http or https and the host is not empty; leave the link out otherwise.
- Set Content-Type: text/html; charset=utf-8 and render into a bytes.Buffer, so a template error becomes a clean 500 rather than half a page.
- Add regression tests that render this card's payloads and parse the page with golang.org/x/net/html, asserting no script element, no on* attribute and no link other than http or https, next to functional tests that text with <, &, quotes and non-ASCII characters displays as typed.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
import (
"io"
"text/template" // the wrong package: text/template knows nothing about HTML
)
// Vulnerable parses the page with text/template, which writes every value exactly as it is:
// tags, quotes and javascript: URLs reach the browser intact (CWE-79).
type Vulnerable struct{}
var vulnerablePage = template.Must(template.New("profile").Parse(profileHTML))
func (Vulnerable) Render(w io.Writer, p Profile) error {
return vulnerablePage.Execute(w, p)
}With IMPL=vulnerable, 7 of 9 tests fail (5 exploit tests).
The fix: html/template
import (
"html/template" // escapes each value for the context it lands in
"io"
"net/url"
)
// Fixed parses the same page with html/template. It escapes each value for where it lands:
// & < > " ' in text and quoted attributes, and URL rules inside href. Only http and https
// websites are linked at all.
type Fixed struct{}
var fixedPage = template.Must(template.New("profile").Parse(profileHTML))
func (Fixed) Render(w io.Writer, p Profile) error {
p.Website = webLink(p.Website)
return fixedPage.Execute(w, p)
}With IMPL=fixed, 9 of 9 tests pass.
The fix: URL scheme allowlist
// webLink returns the website if it is an absolute http or https URL, and "" otherwise.
// Escaping cannot make a URL safe: javascript:alert(1) has nothing to escape. html/template
// would print #ZgotmplZ in its place, but a link to nowhere is still a link.
func webLink(raw string) string {
// url.Parse rejects control characters, so "java\tscript:" fails here.
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return raw
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
import (
"html/template"
"io"
"regexp"
"strings"
)
// Wrong uses html/template, then switches its escaping off where it got in the way: bios keep
// their formatting once script elements are removed, and websites are marked as trusted URLs
// once javascript: is blocked.
type Wrong struct{}
var wrongPage = template.Must(template.New("profile").Parse(profileHTML))
var scriptElement = regexp.MustCompile(`(?is)<script\b.*?</script\s*>`)
func (Wrong) Render(w io.Writer, p Profile) error {
website := p.Website
// html/template printed #ZgotmplZ for some links, so mark the website as safe after
// blocking the scheme from the bug report.
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(website)), "javascript:") {
website = "#"
}
return wrongPage.Execute(w, struct {
Name string
Bio template.HTML
Website template.URL
}{
Name: p.Name,
Bio: template.HTML(scriptElement.ReplaceAllString(p.Bio, "")),
Website: template.URL(website),
})
}With IMPL=wrong, 2 of 9 tests fail (1 exploit test).
The template all three implementations render
<img src="/avatar.svg" alt="{{.Name}}" width="64" height="64">
<h1>{{.Name}}</h1>
<p class="bio">{{.Bio}}</p>
{{- with .Website}}
<a href="{{.}}">Website</a>
{{- end}}The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what a browser would run from the page must stay inert.
// expectNothingExecutable fails on a script element, an event-handler attribute, or a link
// whose scheme is anything but http or https.
func expectNothingExecutable(t *testing.T, p page) {
t.Helper()
for _, n := range p.elements() {
if n.Data == "script" {
t.Errorf("script element: %q", text(n))
}
for _, a := range n.Attr {
if strings.HasPrefix(a.Key, "on") {
t.Errorf("event-handler attribute: %s %s=%q", n.Data, a.Key, a.Val)
}
}
}
for _, href := range p.links() {
if s := urlScheme(href); s != "" && s != "http" && s != "https" {
t.Errorf("link to a %s: URL: %q", s, href)
}
}
}
func TestExploitScriptElementInTheBioDoesNotRun(t *testing.T) {
expectNothingExecutable(t, profile(t, url.Values{"name": {"Mallory"}, "bio": {"<script>alert(document.domain)</script>"}}))
}
func TestExploitEventHandlerAttributeInTheBioDoesNotRun(t *testing.T) {
expectNothingExecutable(t, profile(t, url.Values{"name": {"Mallory"}, "bio": {"<img src=x onerror=alert(document.domain)>"}}))
}
func TestExploitQuoteInTheNameCannotBreakOutOfTheAltAttribute(t *testing.T) {
name := `" onerror="alert(document.domain)`
p := profile(t, url.Values{"name": {name}})
expectNothingExecutable(t, p)
expectText(t, "alt text", avatarAlt(p), name)
}
func TestExploitJavascriptWebsiteIsNotLinked(t *testing.T) {
expectNothingExecutable(t, profile(t, url.Values{"name": {"Mallory"}, "website": {"javascript:alert(document.domain)"}}))
}
func TestExploitTabInsideTheSchemeDoesNotSmuggleAJavascriptURLThrough(t *testing.T) {
// Browsers delete tabs and newlines from a URL before parsing it.
expectNothingExecutable(t, profile(t, url.Values{"name": {"Mallory"}, "website": {"java\tscript:alert(document.domain)"}}))
}- text/template and html/template have the same API, so importing the wrong one compiles, runs and renders without a warning. The text/template documentation points to html/template for HTML output.
- template.HTML, HTMLAttr, JS, URL and the other content types mark a value as trusted and exempt it from escaping; the html/template documentation says each one presents a security risk unless the content comes from a trusted source. A regular expression that removes script elements before template.HTML still lets <img onerror> through, which is this card's wrong fix.
- template.URL also switches off the filter that turns javascript: URLs into #ZgotmplZ, so whatever a blocklist misses is linked: a data: URL came out as a working href in a probe. The tab payload stays inert only because html/template still percent-encodes the tab (java%09script:), which a browser reads as a relative URL.
- #ZgotmplZ in the output is html/template refusing unsafe content in a URL or CSS context. Fix the data or leave the link out; wrapping the value in template.URL to make it disappear is the bug.
- html/template escapes only what passes through the template. fmt.Fprintf(w, ...), io.WriteString and w.Write with request data skip it entirely.
- html/template's security model trusts the template author and not the data. Parsing a template from request data is server-side template injection, whichever package parses it.
- url.Parse rejects a URL with a control character, such as the tab in java\tscript:, but accepts https:evil with an empty host, hence the host check.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xss/go-nethttp does this for you, in Docker if you do not have Go).
- Run semgrep --config r/go.lang.security.audit.xss.import-text-template.import-text-template --config r/go.lang.security.audit.xss.template-html-does-not-escape.unsafe-template-type .; expect the import in impl/vulnerable.go and the two conversions in impl/wrong.go, and nothing in impl/fixed.go.
- Against your own endpoint, send bio=<img src=x onerror=alert(1)> and website=javascript:alert(1) and confirm the tag shows as text and the link is left out.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | go.lang.security.audit.xss.import-text-template.import-text-template | Flagged the text/template import in impl/vulnerable.go; nothing in impl/fixed.go or impl/wrong.go, which import html/template. This is the precise rule for the vulnerable version.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> . |
| Semgrep | go.lang.security.audit.xss.template-html-does-not-escape.unsafe-template-type | Flagged both conversions in impl/wrong.go, template.HTML(...) for the bio and template.URL(...) for the website; nothing in the other files. It matches any conversion to a trusted template type whose argument is not a string literal, so it is the rule that catches the wrong fix.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | go.lang.security.audit.xss.no-fprintf-to-responsewriter.no-fprintf-to-responsewriter | No findings: the card writes HTML only through templates. It is the rule for the other common Go XSS, fmt.Fprintf to the ResponseWriter, and it flagged that call on a probe file. The p/golang pack reported nothing on the card.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | go/reflected-xss | No results on the card. On single-function probes it reported text/template Execute writing request data straight to the ResponseWriter, also through a struct field, and fmt.Fprintf to the ResponseWriter; it reported nothing when the page was rendered into a bytes.Buffer first, nor on a copy of the card that wrote to the ResponseWriter through the Renderer interface.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165) |
| CodeQL | go/html-template-escaping-bypass-xss | No results on the card. On probes it reported template.HTML(request value) passed to html/template Execute, with or without a regexp replacement in between, but not when the converted value travelled in a struct field, which is how impl/wrong.go passes it.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12 |
| gosec | G203Use of unescaped data in HTML templates | Flagged the template.HTML and template.URL conversions in impl/wrong.go (MEDIUM severity, LOW confidence); nothing else.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec -include=G203,G705,G708 ./... |
| gosec | G708Server-side template injection via text/template | No findings, nor from G705 (XSS via taint analysis): the taint analysis does not follow the request value through the Renderer interface into impl/. On a probe file it reported G708 for text/template Execute with request data and G705 for fmt.Fprintf of request data to the ResponseWriter.Checked: gosec 2.29.0 |
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http, html/template) |
| golang.org/x/net (x/net/html, the HTML5 parser the tests read pages with) | 0.60.0 |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xss/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- golang.org/x/net 0.60.0
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.12
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (5 exploit tests); wrong: 2 of 9 tests fail (1 exploit test)
- Test results
- fixed 9/9 pass; vulnerable 7/9 fail (5 exploit tests); wrong 2/9 fail (1 exploit test: the <img onerror> in the bio, plus the bio functional test), with --network=none after install; the same on Go 1.26.9. The vulnerable counts match the Flask, Express and Django cards. The wrong fix fails fewer tests than theirs because html/template keeps escaping the name, a plain string, and still percent-encodes the tab inside template.URL.
- Dependency freshness
- golang.org/x/net 0.60.0 was published on 2026-10-08; it is under seven days old but is a security release (GO-2026-6603, GO-2026-6610, GO-2026-6611, GO-2026-6612 and GO-2026-6617 in x/net/http2), and the tests use only its html package. gotestsum 1.13.0 was published on 2025-09-11; go.mod raises golang.org/x/text from 0.17.0 to 0.42.0 (2026-09-08) because GO-2026-5970 affects x/text/unicode/norm, which gotestsum builds. Go 1.27.2 (2026-10-08) is a security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Go standard library: html/template (contextual escaping and the security model)
- Go standard library: html/template HTML type (a security risk with untrusted content)
- Go standard library: html/template URL type (javascript: URLs are filtered by default)
- Go standard library: text/template (no escaping; see html/template for HTML)
- Go standard library: net/url Parse
- golang.org/x/net/html (HTML5 parser)
- bluemonday: an allowlist-based HTML sanitizer for Go
- OWASP Cross Site Scripting Prevention Cheat Sheet
- WHATWG URL Standard: basic URL parser (tabs and newlines removed)
- gosec 2.29.0 rules
Attack patterns: