Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix cross-site scripting in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks8 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.goGo
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

Fixedimpl/fixed.goGo
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

Fixedimpl/fixed.goGo
// 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

Wrong fiximpl/wrong.goGo
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

impl/profile.htmlHTML
    <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 regression test

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.

Testapp_test.goGo
// 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)"}}))
}

Pitfalls

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

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix cross-site scripting in Go and net/http
ToolRuleWhat it found here
Semgrepgo.lang.security.audit.xss.import-text-template.import-text-templateFlagged 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> .
Semgrepgo.lang.security.audit.xss.template-html-does-not-escape.unsafe-template-typeFlagged 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)
Semgrepgo.lang.security.audit.xss.no-fprintf-to-responsewriter.no-fprintf-to-responsewriterNo 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)
CodeQLgo/reflected-xssNo 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)
CodeQLgo/html-template-escaping-bypass-xssNo 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
gosecG203Use of unescaped data in HTML templatesFlagged 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 ./...
gosecG708Server-side template injection via text/templateNo 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo 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

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# 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.

Shell
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'
Shell
# 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.

Download the project ZIP, 22 KB

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

  1. Go standard library: html/template (contextual escaping and the security model)
  2. Go standard library: html/template HTML type (a security risk with untrusted content)
  3. Go standard library: html/template URL type (javascript: URLs are filtered by default)
  4. Go standard library: text/template (no escaping; see html/template for HTML)
  5. Go standard library: net/url Parse
  6. golang.org/x/net/html (HTML5 parser)
  7. bluemonday: an allowlist-based HTML sanitizer for Go
  8. OWASP Cross Site Scripting Prevention Cheat Sheet
  9. WHATWG URL Standard: basic URL parser (tabs and newlines removed)
  10. gosec 2.29.0 rules

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.