Why Go's html/template Is Only Safe in a script Tag if You Let It Be
Drop a string into an HTML attribute with html/template and it gets HTML-escaped. Drop it into a URL and it gets percent-encoded. Drop it into a <script> block and it comes out as a quoted JavaScript literal, which looks wrong until you see why. The package is not escaping "for HTML". It is escaping for whatever parser will read the output next.
The template parses itself as it goes
Unlike text/template, html/template tracks which context each action sits in: HTML text, attribute, URL, CSS, JavaScript. It then inserts the right escaper into the pipeline at parse time. You never name it.
Here is the script case:
package main
import (
"html/template"
"os"
)
func main() {
t := template.Must(template.New("p").Parse(`<script>
var a = {{.}};
var b = "hi {{.}}";
</script>
`))
evil := `</script><img src=x onerror=alert(1)>`
if err := t.Execute(os.Stdout, evil); err != nil {
panic(err)
}
}
The first line comes out as a complete JSON-style string, with angle brackets written as < and >. The second is escaped as the inside of a string, so you must supply the quotes yourself. Either way the browser's HTML parser never sees a literal </script>.
Why a quoted value is fine in both places
People worry that writing "{{.}}" double-quotes the output. It does not, because the engine knows it is already inside a string literal and switches escaper. The same action gets different escaping depending on where it sits, and that is the whole design.
- Value position (
var a = {{.}}): the value is marshalled as JSON, so strings arrive quoted. - Inside
"..."or'...': the value is escaped as string contents. - Inside a regular expression literal: yet another escaper, for regex text.
Quick detour: how does it know it is in a regex?
This is the clever bit. In JavaScript, / can mean division or the start of a regex, and which one depends on what came before. After x it is division; after ( or = it opens a regex.
The escaper keeps a small state machine that guesses from the preceding token. That is why it can place an action correctly after a slash, and also why it sometimes gives up with an error rather than guess.
Where it refuses to guess
Some positions have no safe answer, and the engine would rather fail at execution than emit something exploitable. The best known is the JavaScript template literal, the backtick string with ${...} in it.
Template literals can run code inside ${}, and your Go action could land in that code or in the literal text, with no cheap way to tell. Newer Go releases return an error for an action inside a backtick string. If you hit it, move the value out of the literal:
// Fails: action inside a JS template literal.
// const msg = `hello {{.Name}}`;
// Works: pass it as a value, build the string in JS.
// const name = {{.Name}};
// const msg = `hello ${name}`;
An older Go release may have let this through, so check your version's release notes before assuming either behaviour.
A cleaner pattern: data island, not inline code
Rather than sprinkling actions through script, emit one JSON blob and read it back:
const page = `<script id="data" type="application/json">{{.}}</script>
<script src="/app.js"></script>`
Then app.js does JSON.parse(document.getElementById("data").textContent). Your script becomes a static file you can cache and put under a strict Content-Security-Policy, and the dynamic part is plain data. Inline script with actions would need a nonce on every request.
What the escaper cannot protect
The escaper guarantees one thing: your value is parsed as a JavaScript literal and not as code or markup. After that, the value is just a string in your program, and what you do with it next is outside the template's reach.
const page = `<script>
var name = {{.Name}};
document.getElementById("greet").innerHTML = "Hello " + name;
</script>`
That is DOM XSS. The Go side did its job perfectly; the string arrived intact as <img src=x onerror=...>, and your innerHTML then turned it into markup. Use textContent instead.
The escape hatches
The package also has types that say "trust me": template.JS, template.HTML, template.URL and friends. Convert a string to template.JS and the escaper stops touching it. That is occasionally right for a constant you wrote yourself.
It is wrong for anything that touched user input, including data from your own database. A quick audit is to grep your codebase for template.JS( and check where each argument came from.
A short checklist
- Keep actions in value position or inside a plain string literal, never inside backticks.
- Prefer one JSON data island over many inline actions.
- Do not wrap user data in
template.JSortemplate.HTML. - In your script, use
textContentandsetAttribute, notinnerHTML, for that data.
The escaping is solid because it is contextual. The unsafe parts are the ones that bypass the context: casts, template literals and your own sinks.