A handler wraps a user's comment in template.HTML to keep its formatting — what breaks?
answer
- a named string type, not a function
- the conversion is an assertion of safety
- escaping stops for that one field
- the attacker controls exactly that value
- sanitise first, convert the output
basics
~20 stemplate.HTML is a named string type meaning "this is already safe markup", so html/template emits it verbatim. Converting attacker-controlled text to it turns escaping off for exactly the field an attacker controls, which is a stored injection.
solid answer
~50 s`template.HTML` is not a formatting helper; it is an assertion. The type says the string is a known-safe HTML fragment, and `html/template` responds by writing it into the document untouched. Wrapping a comment body that arrived in a form post therefore disables the one defence the page had, on the single field an attacker fully controls — and the conversion is one token in a view-model assignment, easy to miss in review. Its siblings behave the same way in their positions: `template.JS`, `template.JSStr`, `template.URL`, `template.HTMLAttr`, `template.CSS`, `template.Srcset`. The fix is not a better wrapper: keep the field a plain `string` so the escaper runs, and meet the real formatting need in the template — render structure with `range` over paragraphs — or run the text through a genuine HTML sanitiser and convert only the sanitiser's output.
code
go · 8 linestype comment struct {
Author string
Body template.HTML // escaping is off for this field
}
// body arrived in a form post and was stored; the conversion is the whole defect
c := comment{Author: author, Body: template.HTML(body)}
_ = tmpl.Execute(w, c)go deeper
Know that template.HTML means "already safe markup" and that user input must never be converted to it. If escaping looks wrong, ask rather than convert.
Explain that the conversion is what disables escaping for that field, and that template.JS, template.JSStr, template.URL, template.HTMLAttr and template.CSS do the same in their own positions.
Diagnose it in review: find the conversion, trace the value back to the request, name the real formatting need behind it, and prove the fix with a rendering test asserting the escaped bytes for a hostile input.
Own the rule that stops it recurring: one reviewed place where such conversions may live, a sanctioned sanitising path for the requirement that motivates them, and a check that fails the build on new sites.
## What the type actually means `html/template` declares a handful of named string types — `HTML`, `HTMLAttr`, `JS`, `JSStr`, `CSS`, `URL`, `Srcset`. Each is `type X string`. They carry no validation and no behaviour. What they carry is a claim, addressed to the escaping pass: *a value of this type is already safe in this kind of position, so emit it as-is*. The package documentation for `HTML` is explicit that it encapsulates a known-safe document fragment and should not be used for HTML from a third party. So `template.HTML(body)` is a conversion, not a function call, and it is the only mechanism by which escaping stops. There is no flag, no option, no `Unsafe` setting. If a value reaches the page unescaped, somewhere a conversion to one of these types happened. ## Why this particular bug keeps happening It is almost always the tail of a legitimate complaint. Someone renders a comment body and sees `<br>` where they wanted a line break, or sees `'` in place of an apostrophe, and concludes the template is over-escaping. The change that makes the symptom go away is one word wide: ``` Body: template.HTML(row.Body) ``` It renders beautifully, the ticket closes, and the escaper is now off for the field whose contents come straight from an untrusted form post. Every other field on the page is still protected, which is what makes it so hard to spot: the page as a whole looks defended. A reviewer looking at this diff should do three things. Find the conversion — a grep for `template.HTML(`, `template.JS(`, `template.URL(` across the diff is mechanical and fast. Trace the value backwards to its origin: if the chain ends at a request, a database row that a request wrote, a header, a filename, or a third-party API, the conversion is a defect regardless of how the value looks today. And ask what the conversion was *for*, because that is where the real fix lives. ## The fixes, in order of preference **Keep it a `string` and change the template.** If the goal was line breaks, store the text and render paragraphs: `{{range .Paragraphs}}<p>{{.}}</p>{{end}}`. The structure comes from the template, which you wrote, and the text stays escaped. This handles the large majority of real cases. **Escape a narrower thing.** If only a URL was the problem, do not reach for `template.URL`; parse the value with `net/url`, allow only the schemes you intend, and pass the result as a plain string so the URL escaper still runs. **Sanitise, then convert.** If the product genuinely requires user-authored markup, the value must pass through an HTML sanitiser that parses the document and rebuilds it from an allowlist of elements and attributes. Only the sanitiser's output is converted, and the conversion lives immediately next to the sanitiser call so the two cannot drift apart. Note what this does not include: stripping `<script>` with a string replacement is not sanitising, because the attack surface is attributes and URLs as much as elements. **Legitimate uses that remain.** Developer-authored constant markup, and the output of another `html/template` execution that you are composing into a page — both are safe because neither is attacker-controlled. ## Proving the fix The cheap, durable evidence is a rendering test: execute the real template with a hostile string and assert the exact bytes for each position the value can occupy. A test that asserts `<script>` appears and `<script>` does not will fail the moment somebody reintroduces the conversion, including in a refactor months later. Extend the same table to the attribute and URL positions on that page — a hostile value that is harmless in body text may still be aimed at an `href`. Keeping the assertion on the rendered bytes rather than on internal state is what makes the test survive changes to the view model. ## The shape of the answer an interviewer wants Name the mechanism (a named string type asserting safety), name the consequence (escaping is off for exactly the untrusted field), name the sibling types so it is clear the problem is not specific to `HTML`, refuse the tempting non-fix (stripping tags by hand), and finish on the structural fix plus the test that keeps it fixed.
- When is converting a value to template.HTML actually legitimate?When the string is not attacker-controlled: markup written by developers as a constant, or the output of another html/template execution being composed into a page. It is also legitimate immediately after a real HTML sanitiser that parses and rebuilds the document from an allowlist — with the conversion sitting next to that call so the two cannot be separated later.
- A developer wraps a user's profile link in template.URL because the escaper mangled it. What do you tell them?That template.URL skips the scheme filter, so a `javascript:` link now renders as written. The mangling is usually the whole-URL escaper being handed an already-built URL; instead parse it with net/url, allow only the schemes you intend, and pass the pieces as plain strings so the template escapes each part in its own position.
- How do you find these conversions across a large codebase rather than one diff?They are grep-able by construction: the conversions are literal `template.HTML(`, `template.JS(`, `template.URL(`, `template.HTMLAttr(` text. Sweep the repository, keep the survivors in one small package behind named constructors, and add a check that fails when a conversion appears outside it.
- Is it enough to strip <script> tags from the comment before converting?No. Injection in markup lives in attributes, event handlers and URLs as much as in elements, and a string replacement is trivially bypassed by malformed or nested input. Either the value goes through a parser-based sanitiser or it stays a plain string and gets escaped.
It is the visitor badge that lets someone skip the metal detector. Handing one to a stranger because the queue annoyed them is not a shortcut, it is the whole failure.
saying these in an interview costs you the question
- Wraps user input in template.HTML to stop over-escaping
- Says the value is safe because it came from our database
- Strips <script> with a string replace and calls it sanitised
- Thinks template.URL only changes percent-encoding
- Treats the conversion as a formatting helper