rgoussu@goussu: ~/library/go/go-security
~/library/go/go-security cat web-security.md

Web security in Go apps

# The OWASP web attack surface in Go idiom — injection, XSS, CSRF, CORS, headers, sessions, file handling, SSRF and request limits.

Conceptsaved 2026-08-09 #go#security#web#owasp#middleware

Overview

The OWASP concerns are universal; what changes in Go is where the defence lives. Some of it the language design quietly solved (html/template's contextual escaping is one of the best XSS answers in any ecosystem; no reflective deserialisation gadget chains), some of it is a one-line library (rs/cors, gorilla/csrf), and some of it is entirely on you because no framework sets defaults — security headers being the canonical example, where Spring users get sane values for free and Go users get nothing until they add middleware. The general taxonomy lives in AppSec fundamentals; this note is the Go-specific defence per concern.

Key points

  • Injection: database/sql placeholders ($1/?) end SQL injection; the absence of ORM magic means there is also nowhere to hide string concatenation — grep for fmt.Sprintf feeding Query and you have your audit.
  • XSS: html/template auto-escapes contextually (HTML body vs attribute vs JS vs URL); text/template does not escape at all and is the classic footgun when someone reaches for it to render HTML.
  • CSRF: gorilla/csrf for cookie-session apps; for pure bearer-token APIs CSRF is largely moot, and SameSite=Lax/Strict cookies have downgraded (not eliminated) the risk elsewhere — judge per app.
  • CORS: rs/cors as net/http middleware, or the per-framework equivalents (gin-contrib/cors, echo's middleware.CORS); never reflect arbitrary origins with credentials enabled.
  • Headers: unrolled/secure (HSTS, CSP, frame/content-type options) or ten lines of hand-rolled middleware — but it must exist; nothing is emitted by default.
  • Sessions: gorilla/sessions (cookie or store-backed) or the more modern alexedwards/scs (server-side, pluggable stores, sane renewal API); regenerate the session ID on login either way.
  • File paths: filepath.Clean alone does not stop traversal; use os.Root (Go 1.24) to confine all file operations under a directory, or cyphar/filepath-securejoin before that.
  • SSRF: validate/allowlist target hosts and control the dialer — resolve-then-check is bypassable via DNS rebinding, so enforce in net.Dialer.Control on the actual connected IP.
  • Resource limits: http.MaxBytesReader on request bodies, explicit limits when decompressing client-supplied gzip/zip (decompression bombs), and Server timeouts (ReadHeaderTimeout et al.) which are unset by default.

Details

Injection beyond SQL

database/sql (and pgx natively) parameterise values, not identifiers — table/column names still need an allowlist. Query builders (squirrel) and generators (sqlc) keep parameterisation while composing dynamically. The same discipline generalises: os/exec.Command takes an argv slice, so shell injection only appears when someone invokes sh -c with concatenated input; LDAP/NoSQL injection follow the usual encoding rules from AppSec fundamentals.

html/template's contextual model

The escaper parses the template, determines the context of every interpolation point, and applies the right encoding chain (htmlEscaper, jsValEscaper, urlFilter, …). Typed bypasses — template.HTML, template.JS, template.URL — are the explicit "I promise this is safe" markers; audit every constructor of those types. Remaining foot-guns: rendering user HTML on purpose (sanitise with microcosm-cc/bluemonday first), and template injection — never build a template string from user input (template.New(...). Parse(userInput) hands over the escaper itself); user data belongs in the data argument, not the template text.

CSRF and sessions in practice

gorilla/csrf issues a masked double-submit token tied to the session and rejects mutating requests without it; wire it around the router and expose the token via csrf.Token(r)/TemplateField. For APIs authenticated by Authorization headers there is no ambient credential, hence no CSRF — the judgement call is hybrid apps where a cookie session backs a JSON API: keep CSRF there, or move fully to SameSite=Strict plus re-authentication for sensitive actions. With scs, LoadAndSave middleware handles cookie lifecycle; call RenewToken on privilege change to prevent session fixation.

The unset-by-default tax

Worth a literal checklist because nothing supplies it (contrast Spring Security's defaults):

Concern Fix
Security headers unrolled/secure or hand-rolled: HSTS, CSP, X-Content-Type-Options: nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy
Server timeouts set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout on http.Server
Body size http.MaxBytesReader(w, r.Body, n) before decoding
Decompression wrap gzip readers with io.LimitReader; check zip entry sizes before extraction
Error leakage never http.Error(w, err.Error(), 500) with internal errors

Examples

// Path traversal: confine file access with os.Root (Go 1.24+).
root, err := os.OpenRoot("/var/app/uploads")
if err != nil { return err }
defer root.Close()
f, err := root.Open(userSuppliedName) // cannot escape /var/app/uploads, symlinks included
// SSRF: enforce on the connected IP, not the looked-up name.
dialer := &net.Dialer{
    Control: func(network, address string, c syscall.RawConn) error {
        host, _, _ := net.SplitHostPort(address)
        if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) {
            return fmt.Errorf("blocked internal address %s", ip)
        }
        return nil
    },
}
client := &http.Client{Transport: &http.Transport{DialContext: dialer.DialContext}}

Related