Skip to content
gohacks.dev
Web

Templ Mistakes With Escaping and Raw HTML in Components

Where templ escapes for you and where it does not: URLs, SafeURL, style strings, Raw, script data and event handlers, shown with real output and tests.

10 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the HTML5 logo.

Automatic escaping is the reason to use a template engine that understands HTML, and it is easy to take as a guarantee when it is a set of defaults with specific exits. templ escapes in the places you expect. It also has a handful of functions whose job is to switch the protection off, and a few spots where the protection stops short of what you might assume. A cross-site scripting bug in a templ application almost always lives in one of those, so this article tests each one and reads the output.

Tested with Go 1.25.6, templ v0.3.1001 and bluemonday v1.0.27, macOS on Apple Silicon (arm64)

It builds on the tutorial and the first component, applies the same thinking as rendering Markdown safely and the html/template article, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001 and bluemonday v1.0.27.

The components under test

One component for each context a value can land in: text, an attribute, a link, a style, a class, raw markup, a data attribute, a JSON blob for scripts and an event handler.

esc.templText
package main

templ Text(s string) {
	<p>{ s }</p>
}

templ Attr(s string) {
	<p title={ s }>x</p>
}

templ Link(s string) {
	<a href={ templ.URL(s) }>link</a>
}

templ LinkSafe(s string) {
	<a href={ templ.SafeURL(s) }>link</a>
}

templ Style(s string) {
	<p style={ s }>x</p>
}

templ Class(s string) {
	<p class={ s }>x</p>
}

templ RawHTML(s string) {
	<div>
		@templ.Raw(s)
	</div>
}

templ DataAttr(s string) {
	<div data-user={ s }>x</div>
}

templ Config(v map[string]string) {
	@templ.JSONScript("cfg", v)
}

templ Button(s string) {
	<button onclick={ templ.JSFuncCall("greet", s) }>hi</button>
}

What each context does with hostile input

main.goGo
package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/a-h/templ"
	"github.com/microcosm-cc/bluemonday"
)

func show(label string, c templ.Component) {
	var sb strings.Builder
	err := c.Render(context.Background(), &sb)
	fmt.Printf("%-26s %s", label, sb.String())
	if err != nil {
		fmt.Printf("  ERROR: %v", err)
	}
	fmt.Println()
}

func main() {
	hostile := `"><img src=x onerror=alert(1)>`
	fmt.Println("-- escaped by default")
	show("text", Text("<script>alert(1)</script>"))
	show("attribute value", Attr(hostile))
	show("class", Class(`a b" onclick="x`))
	show("data attribute", DataAttr(`{"a":"<b>"}`))

	fmt.Println("-- URLs")
	show("URL(), https", Link("https://go.dev/doc?a=1&b=2"))
	show("URL(), javascript:", Link("javascript:alert(1)"))
	show("URL(), JaVaScRiPt:", Link("JaVaScRiPt:alert(1)"))
	show("URL(), data:", Link("data:text/html,<script>1</script>"))
	show("SafeURL(), javascript:", LinkSafe("javascript:alert(1)"))

	fmt.Println("-- style: a string is not sanitised")
	show("style attribute", Style("background:url(javascript:alert(1));position:fixed;inset:0"))

	fmt.Println("-- data for scripts")
	show("JSONScript", Config(map[string]string{"k": "</script><script>alert(1)"}))
	show("JSFuncCall argument", Button(`'); alert(1); ('`))

	fmt.Println("-- the escape hatch")
	show("Raw, unsanitised", RawHTML("<b>bold</b><script>alert(1)</script>"))
	policy := bluemonday.UGCPolicy()
	show("Raw after bluemonday", RawHTML(policy.Sanitize("<b>bold</b><script>alert(1)</script><a href=\"javascript:x\">go</a>")))
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go get github.com/microcosm-cc/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run .
Text
-- escaped by default
text                       <p>&lt;script&gt;alert(1)&lt;/script&gt;</p>
attribute value            <p title="&#34;&gt;&lt;img src=x onerror=alert(1)&gt;">x</p>
class                      <p class="a b&#34; onclick=&#34;x">x</p>
data attribute             <div data-user="{&#34;a&#34;:&#34;&lt;b&gt;&#34;}">x</div>
-- URLs
URL(), https               <a href="https://go.dev/doc?a=1&amp;b=2">link</a>
URL(), javascript:         <a href="about:invalid#TemplFailedSanitizationURL">link</a>
URL(), JaVaScRiPt:         <a href="about:invalid#TemplFailedSanitizationURL">link</a>
URL(), data:               <a href="about:invalid#TemplFailedSanitizationURL">link</a>
SafeURL(), javascript:     <a href="javascript:alert(1)">link</a>
-- style: a string is not sanitised
style attribute            <p style="background:url(javascript:alert(1));position:fixed;inset:0;">x</p>
-- data for scripts
JSONScript                 <script id="cfg" type="application/json">{"k":"\u003c/script\u003e\u003cscript\u003ealert(1)"}
</script>
JSFuncCall argument        <button onclick="greet(&#34;&#39;); alert(1); (&#39;&#34;)">hi</button>
-- the escape hatch
Raw, unsanitised           <div><b>bold</b><script>alert(1)</script></div>
Raw after bluemonday       <div><b>bold</b>go</div>

Read the output by section.

Escaped by default. Text, attribute values, class names and data- attributes all came out HTML-escaped. The attribute test is the telling one: the input "><img src=x onerror=alert(1)> tried to close the title attribute and open an element, and arrived as &#34;&gt;&lt;img ..., with the quote and the angle brackets neutralised. The class test did the same for an attempt to inject an onclick. For these contexts you do not need to do anything, and nothing you add makes them safer.

URLs need the right function. A javascript: link in an href is the classic script injection, and the case that deserves care is the choice between two similar-looking helpers. templ.URL(s) took javascript:alert(1), the mixed-case spelling and a data:text/html URL and replaced each with the harmless about:invalid#TemplFailedSanitizationURL, while an ordinary https URL with a query string passed (the & correctly became &amp;). templ.SafeURL(s) is a different promise: it tells the library the string is already safe, and it emitted href="javascript:alert(1)" untouched. Use SafeURL only for URLs you constructed from constants and known-good pieces in your own code, never for a value that came from a user, a database field a user could edit, or a query parameter.

Styles are not sanitised. The style attribute received the string background:url(javascript:alert(1));position:fixed;inset:0, and every part of it reached the page. Modern browsers do not run script from a CSS url(), which makes the javascript: part inert, but the rest shows the real risk: user-controlled CSS can cover the page with an invisible full-screen element (position:fixed;inset:0), restyle a login form into a phishing lookalike or probe with background images to leak data. Do not interpolate untrusted strings into style. Choose from a fixed set of class names, or validate against a strict pattern (a hex colour, a number with a unit) before building the value.

Data for scripts. templ.JSONScript wrote the JSON for a map whose value was </script><script>alert(1), with the angle brackets written as Unicode escapes, so the payload cannot close the script element it sits in. templ.JSFuncCall("greet", s) produced an onclick that calls greet with the argument as a JSON string literal inside an escaped attribute; the '); alert(1); (' payload stayed one harmless string. These two are the safe ways to get server data into JavaScript. The unsafe way, which no helper protects you from, is building a script by concatenating strings.

The escape hatch. templ.Raw wrote <b>bold</b><script>alert(1)</script> exactly as given. That is its purpose (rendered Markdown, trusted CMS content, an SVG icon), and the danger is passing it something that is not trusted. The last line shows the standard remedy: the same input run through bluemonday's UGCPolicy first became <b>bold</b>go, with the script removed and the javascript: link reduced to its text. The pipeline for any HTML that originates outside your code is: parse or render, sanitize the HTML, then templ.Raw, in that order, as in the goldmark article.

Mistakes I would look for in a review

  1. templ.SafeURL(user) or templ.Raw(user). Search the code base for both; each call needs a one-line reason in a comment. They should be rare.
  2. Untrusted data in style, in an event handler written as a string, or in a <script> body. The first has no filter. Event handlers should use templ.JSFuncCall or a script template, and a script block should read its data from a JSONScript element or a data- attribute.
  3. A helper that returns HTML. A Go function returning templ.Component built with templ.Raw, or a template.HTML value converted at a distance from the user input, hides the hatch from the reader. Keep Raw at the call site, where it can be reviewed.
  4. Trusting the database. Stored data is user input that has been to a database. A column that "only admins can edit" is one compromised account away from being hostile.
  5. Assuming sanitised once means safe forever. Sanitize at output time, with the policy you have today, rather than at input time. If the policy tightens, old data benefits.

A second lock: the Content-Security-Policy

Escaping is the first line of defence, and a Content-Security-Policy is the second. A policy such as default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none' tells the browser to refuse inline scripts and event handler attributes entirely, so even an escaping mistake that injects <img onerror=...> runs nothing. The price is that your own pages cannot use inline scripts or onclick attributes, which is a good constraint: put scripts in files, and bind events there. When you do need an inline script, give the policy a nonce (a random value per response) and put the same nonce on the script tag. templ supports this with templ.WithNonce(ctx, nonce) in your middleware and templ.GetNonce(ctx) in components, and the components it generates for scripts add the attribute for you. Send the header from middleware, test it by loading your pages with the browser's console open, and start in report-only mode (Content-Security-Policy-Report-Only) to find what would break before enforcing.

Pin the behavior with a test

The program shows the behavior; a test keeps it from changing without anyone noticing. This one feeds a list of payloads to every default context and fails if a live element, an event handler or a script break-out appears in the output, checks the URL sanitiser on several schemes and a relative URL, and records the two escape hatches as hatches, so that if a future version of the library changes what they do, the test fails and the review checklist gets updated.

esc_test.goGo
package main

import (
	"context"
	"strings"
	"testing"

	"github.com/a-h/templ"
)

func render(t *testing.T, c templ.Component) string {
	t.Helper()
	var sb strings.Builder
	if err := c.Render(context.Background(), &sb); err != nil {
		t.Fatal(err)
	}
	return sb.String()
}

var payloads = []string{
	`<script>alert(1)</script>`,
	`"><img src=x onerror=alert(1)>`,
	`' onmouseover='alert(1)`,
	`javascript:alert(1)`,
	`</script><script>alert(1)`,
}

func TestEveryDefaultContextNeutralisesThePayloads(t *testing.T) {
	for _, p := range payloads {
		for name, c := range map[string]templ.Component{
			"text": Text(p), "attribute": Attr(p), "class": Class(p), "data": DataAttr(p),
			"json": Config(map[string]string{"k": p}), "jsfunc": Button(p),
		} {
			out := render(t, c)
			// An element start that was not in the template, or a bare event handler, means escaping failed.
			if strings.Contains(out, "<script>alert") || strings.Contains(out, "<img") || strings.Contains(out, `" onmouseover=`) || strings.Contains(out, "</script><script>") {
				t.Errorf("%s with %q produced %s", name, p, out)
			}
		}
	}
}

func TestURLRefusesDangerousSchemes(t *testing.T) {
	for _, p := range []string{"javascript:alert(1)", "JAVASCRIPT:alert(1)", " javascript:alert(1)", "data:text/html,x", "vbscript:x"} {
		if out := render(t, Link(p)); !strings.Contains(out, "about:invalid#TemplFailedSanitizationURL") {
			t.Errorf("%q was not sanitised: %s", p, out)
		}
	}
	if out := render(t, Link("/relative/path?x=1")); !strings.Contains(out, `href="/relative/path?x=1"`) {
		t.Errorf("a relative URL was damaged: %s", out)
	}
}

func TestSafeURLAndRawAreTheOnlyHatches(t *testing.T) {
	// If this test fails because the library started sanitising these, update the article and the review checklist.
	if !strings.Contains(render(t, LinkSafe("javascript:alert(1)")), `href="javascript:alert(1)"`) {
		t.Error("SafeURL no longer passes its input through")
	}
	if !strings.Contains(render(t, RawHTML("<i>x</i>")), "<i>x</i>") {
		t.Error("Raw no longer passes markup through")
	}
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go get github.com/microcosm-cc/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go test -race -count=1 -v ./... 2>&1 | grep -E "^(--- |ok|FAIL|PASS)"
Text
--- PASS: TestEveryDefaultContextNeutralisesThePayloads (0.00s)
--- PASS: TestURLRefusesDangerousSchemes (0.00s)
--- PASS: TestSafeURLAndRawAreTheOnlyHatches (0.00s)
PASS
ok  	example.test/lab	1.277s

Add a payload every time you read about a bypass; the OWASP XSS filter evasion cheat sheet and the PortSwigger lists are rich sources. Combine it with a Content-Security-Policy that forbids inline scripts, so a missed hole becomes a console error and not a breach, and with the discipline of the review list above. The functions discussed are documented in the templ guide and on pkg.go.dev, and MDN has the best plain-language description of cross-site scripting.

Also inSecurity
Taggedhardening
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.