Go template.HTML XSS Vulnerability

Critical Risk Cross-site Scripting
goxsstemplate-injectionhtml-templatetemplate-htmlwebinjectionuser-inputauto-escapingcross-site-scripting

What it is

A critical vulnerability that occurs when Go applications using the html/template package inappropriately use template.HTML to bypass auto-escaping mechanisms. By wrapping user input with template.HTML, developers disable the built-in XSS protections, allowing attackers to inject malicious HTML and JavaScript code directly into web pages.

package main

import (
    "fmt"
    "html/template"
    "net/http"
)

func vulnerableHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    message := r.URL.Query().Get("message")
    theme := r.URL.Query().Get("theme")

    // Vulnerable: template.HTML with formatted strings
    welcomeHTML := template.HTML(fmt.Sprintf("<h1>Welcome %s!</h1>", name))

    // Vulnerable: String concatenation + template.HTML
    messageHTML := template.HTML("<div class='" + theme + "'>" + message + "</div>")

    // Vulnerable: Complex formatting with user data
    pageHTML := template.HTML(fmt.Sprintf(`
        <html>
        <body>
            %s
            %s
            <footer>Powered by Go</footer>
        </body>
        </html>
    `, welcomeHTML, messageHTML))

    w.Header().Set("Content-Type", "text/html")
    fmt.Fprint(w, pageHTML)
}

func profileHandler(w http.ResponseWriter, r *http.Request) {
    bio := r.PostFormValue("bio")
    signature := r.PostFormValue("signature")

    // Vulnerable: Direct template.HTML usage
    profileHTML := template.HTML(fmt.Sprintf(`
        <div class="profile">
            <div class="bio">%s</div>
            <div class="signature">%s</div>
        </div>
    `, bio, signature))

    fmt.Fprint(w, profileHTML)
}

// Attack vectors:
// ?name=<script>alert('XSS')</script>
// ?message=<img src=x onerror="fetch('/admin/delete')">
// ?theme=" onload="document.location='//evil.com'
// bio=<iframe src="javascript:alert('Stored XSS')"></iframe>
package main

import (
    "html/template"
    "net/http"
    "github.com/microcosm-cc/bluemonday"
)

// Safe: Structured data for templates
type PageData struct {
    Name    string
    Message string
    Theme   string
}

type ProfileData struct {
    Bio       string
    Signature string
    RichBio   template.HTML // Only for sanitized content
}

// Safe: Pre-compiled templates with auto-escaping
var pageTemplate = template.Must(template.New("page").Parse(`
<html>
<body>
    <h1>Welcome {{.Name}}!</h1>
    <div class="{{.Theme}}">{{.Message}}</div>
    <footer>Powered by Go</footer>
</body>
</html>
`))

var profileTemplate = template.Must(template.New("profile").Parse(`
<div class="profile">
    <div class="bio">{{.Bio}}</div>
    <div class="signature">{{.Signature}}</div>
    {{if .RichBio}}<div class="rich-bio">{{.RichBio}}</div>{{end}}
</div>
`))

// HTML sanitization policy
var htmlPolicy = bluemonday.UGCPolicy()

func safeHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    message := r.URL.Query().Get("message")
    theme := r.URL.Query().Get("theme")

    // Validate theme against whitelist
    allowedThemes := map[string]bool{
        "light": true, "dark": true, "blue": true,
    }
    if !allowedThemes[theme] {
        theme = "light" // Default safe value
    }

    // Safe: Structured data with auto-escaping
    data := PageData{
        Name:    name,    // Auto-escaped by template
        Message: message, // Auto-escaped by template
        Theme:   theme,   // Validated against whitelist
    }

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    pageTemplate.Execute(w, data)
}

func safeProfileHandler(w http.ResponseWriter, r *http.Request) {
    bio := r.PostFormValue("bio")
    signature := r.PostFormValue("signature")
    richContent := r.PostFormValue("rich_content")

    // Option 1: Plain text (recommended)
    data := ProfileData{
        Bio:       bio,       // Auto-escaped
        Signature: signature, // Auto-escaped
    }

    // Option 2: If rich content is needed, sanitize first
    if richContent != "" {
        safeHTML := htmlPolicy.Sanitize(richContent)
        data.RichBio = template.HTML(safeHTML) // Safe after sanitization
    }

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    profileTemplate.Execute(w, data)
}

// Alternative: JSON API approach
func apiHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    message := r.URL.Query().Get("message")

    w.Header().Set("Content-Type", "application/json")

    // Safe: JSON encoding handles escaping
    jsonResponse := fmt.Sprintf(`{"name":"%s","message":"%s"}`,
        template.JSEscapeString(name),
        template.JSEscapeString(message))

    fmt.Fprint(w, jsonResponse)
}

💡 Why This Fix Works

The vulnerable code uses template.HTML with formatted strings containing user input, bypassing auto-escaping. The fixed version uses structured templates with auto-escaping and bluemonday for HTML sanitization.

Why it happens

Using template.HTML to wrap user input directly bypasses Go's built-in XSS protections. This is often done mistakenly when developers want to preserve HTML formatting but don't realize they're creating a security vulnerability.

Root causes

Direct template.HTML Wrapping of User Input

Using template.HTML to wrap user input directly bypasses Go's built-in XSS protections. This is often done mistakenly when developers want to preserve HTML formatting but don't realize they're creating a security vulnerability.

Preview example – GO
// VULNERABLE: Direct wrapping with template.HTML
func vulnerableHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    // Bypasses auto-escaping - XSS vulnerability!
    welcomeHTML := template.HTML(fmt.Sprintf("<h1>Welcome %s!</h1>", name))
    // Malicious input: <script>alert('XSS')</script>
}

String Formatting with template.HTML

Combining string formatting functions like fmt.Sprintf with template.HTML creates injection vulnerabilities. This pattern is particularly dangerous because it appears to be building HTML structure safely but actually disables all escaping.

Preview example – GO
// VULNERABLE: fmt.Sprintf + template.HTML
func displayMessage(message, theme string) template.HTML {
    // No escaping applied to user input
    return template.HTML(fmt.Sprintf("<div class='%s'>%s</div>", theme, message))
    // Attack: theme = "" onload="alert('XSS')"
    // Attack: message = "<img src=x onerror='steal_cookies()'>"
}

Complex HTML Construction with User Data

Building complex HTML structures by concatenating strings and wrapping the result with template.HTML creates multiple injection points. Each piece of user data becomes a potential attack vector when auto-escaping is disabled.

Preview example – GO
// VULNERABLE: Complex HTML building
func buildProfileHTML(bio, signature string) template.HTML {
    html := "<div class='profile'>" +
            "<div class='bio'>" + bio + "</div>" +
            "<div class='signature'>" + signature + "</div>" +
            "</div>"
    return template.HTML(html) // Multiple injection points
}

Misunderstanding of template.HTML Purpose

Developers often misuse template.HTML thinking it provides some form of HTML validation or sanitization. In reality, template.HTML is only for marking already-safe, trusted HTML content and should never be used with user input.

Preview example – GO
// VULNERABLE: Misusing template.HTML as sanitization
func processUserContent(content string) template.HTML {
    // WRONG: template.HTML doesn't sanitize or validate
    // It just marks content as "safe" and bypasses escaping
    return template.HTML(content) // Direct XSS vulnerability
}

Fixes

1

Use Structured Templates with Auto-Escaping

Instead of using template.HTML, define structured templates and pass user data as regular string values. Go's html/template package will automatically escape all user input, preventing XSS attacks while maintaining clean HTML structure.

View implementation – GO
// SECURE: Structured template with auto-escaping
type PageData struct {
    Name    string
    Message string
    Theme   string
}

// Pre-compiled template with automatic escaping
var pageTemplate = template.Must(template.New("page").Parse(`
<html>
<body>
    <h1>Welcome {{.Name}}!</h1>
    <div class="{{.Theme}}">{{.Message}}</div>
</body>
</html>
`))

func safeHandler(w http.ResponseWriter, r *http.Request) {
    data := PageData{
        Name:    r.URL.Query().Get("name"),    // Auto-escaped
        Message: r.URL.Query().Get("message"), // Auto-escaped
        Theme:   validateTheme(r.URL.Query().Get("theme")),
    }
    pageTemplate.Execute(w, data)
}
2

Implement Input Validation and Whitelisting

Validate and whitelist user input before using it in templates. For dynamic values like CSS classes or IDs, maintain a list of allowed values and reject anything that doesn't match.

View implementation – GO
// Input validation and whitelisting
func validateTheme(theme string) string {
    allowedThemes := map[string]bool{
        "light": true,
        "dark":  true,
        "blue":  true,
    }
    if allowedThemes[theme] {
        return theme
    }
    return "light" // Safe default
}

func validateUserID(id string) (string, error) {
    // Only allow alphanumeric characters
    if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, id); matched && len(id) <= 50 {
        return id, nil
    }
    return "", errors.New("invalid user ID")
}

func safeElementHandler(w http.ResponseWriter, r *http.Request) {
    userID, err := validateUserID(r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }
    
    data := struct{ ID string }{ID: userID}
    template.Must(template.New("elem").Parse(`<div id="{{.ID}}">Content</div>`)).Execute(w, data)
}
3

Use HTML Sanitization for Rich Content

When you need to allow some HTML content (like from a rich text editor), use a proper HTML sanitization library like bluemonday instead of template.HTML. Only mark content as template.HTML after it has been thoroughly sanitized.

View implementation – GO
// SECURE: HTML sanitization with bluemonday
import "github.com/microcosm-cc/bluemonday"

// Create a strict policy for user content
var htmlPolicy = bluemonday.StrictPolicy()

// Or a more permissive policy for rich content
var richContentPolicy = bluemonday.UGCPolicy()

func displayRichContent(w http.ResponseWriter, r *http.Request) {
    userContent := r.PostFormValue("content")
    
    // Sanitize HTML content
    safeHTML := richContentPolicy.Sanitize(userContent)
    
    // Only now is it safe to use template.HTML
    data := struct{ Content template.HTML }{
        Content: template.HTML(safeHTML),
    }
    
    tmpl := template.Must(template.New("rich").Parse(`
        <div class="rich-content">{{.Content}}</div>
    `))
    tmpl.Execute(w, data)
}
4

Use Context-Aware Escaping Functions

For specific contexts like JavaScript or CSS, use Go's context-aware escaping functions to ensure proper escaping for each context. Never use template.HTML for JavaScript or CSS contexts.

View implementation – GO
// SECURE: Context-aware escaping
func safeJavaScriptHandler(w http.ResponseWriter, r *http.Request) {
    userName := r.URL.Query().Get("name")
    
    // Use JSEscapeString for JavaScript context
    safeJSName := template.JSEscapeString(userName)
    
    tmpl := template.Must(template.New("js").Parse(`
    <script>
        var userName = "{{.SafeName}}";
        console.log("Hello, " + userName);
    </script>
    `))
    
    data := struct{ SafeName template.JS }{
        SafeName: template.JS(safeJSName),
    }
    tmpl.Execute(w, data)
}

// For URLs, use URLQueryEscaper
func safeURLHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    
    tmpl := template.Must(template.New("url").Parse(`
        <a href="/search?q={{.Query}}">Search for {{.QueryText}}</a>
    `))
    
    data := struct{ Query template.URL; QueryText string }{
        Query:     template.URL(template.URLQueryEscaper(query)),
        QueryText: query, // Auto-escaped as text
    }
    tmpl.Execute(w, data)
}
5

Implement Content Security Policy (CSP)

Add Content Security Policy headers as an additional layer of protection against XSS attacks. CSP can help mitigate XSS vulnerabilities even when they exist in your code.

View implementation – GO
// SECURE: Add CSP headers for defense in depth
func addSecurityHeaders(w http.ResponseWriter) {
    // Strict CSP to prevent inline scripts and styles
    w.Header().Set("Content-Security-Policy", 
        "default-src 'self'; "+
        "script-src 'self'; "+
        "style-src 'self' 'unsafe-inline'; "+
        "img-src 'self' data:; "+
        "font-src 'self'; "+
        "connect-src 'self'; "+
        "frame-ancestors 'none'")
    
    // Additional security headers
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.Header().Set("X-Frame-Options", "DENY")
    w.Header().Set("X-XSS-Protection", "1; mode=block")
    w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
}

func secureHandler(w http.ResponseWriter, r *http.Request) {
    addSecurityHeaders(w)
    
    // Your safe template rendering here
    data := struct{ Name string }{Name: r.URL.Query().Get("name")}
    template.Must(template.New("safe").Parse(`<h1>Hello {{.Name}}</h1>`)).Execute(w, data)
}

Detect This Vulnerability in Your Code

Sourcery automatically identifies go template.html xss vulnerability and many other security issues in your codebase.