The moment untrusted data breaks your app
You ship a comment form. Users type in text. You store it. You display it back. Someone types <script>document.cookie</script>. Your application just handed their session to a stranger. Or they type ../../../etc/passwd into a file viewer. Suddenly your server is reading system configuration files. Input handling is where most Go applications get compromised. The language does not stop you from making these mistakes, but it gives you tools that make the safe path the default.
Validation versus sanitization
Validation and sanitization are two separate jobs. Validation asks whether the input matches what you expect. It checks length, format, type, and allowed characters. If the input fails validation, you reject it immediately. Sanitization transforms input so it works safely in a specific context. You do not sanitize blindly. You validate first, then render or store the data using a tool that understands the target context. Go's standard library leans heavily on context-aware rendering instead of manual string scrubbing.
Validate at the boundary. Sanitize at the boundary. Never trust the middle.
HTML rendering with context awareness
Here is the simplest way to render user input safely in a web response. The html/template package handles escaping automatically.
package main
import (
"html/template"
"os"
)
func main() {
// Parse the template once. Must panics if the syntax is invalid.
tmpl := template.Must(template.New("comment").Parse("<p>{{.}}</p>"))
// Execute writes to stdout. The package tracks context and escapes automatically.
err := tmpl.Execute(os.Stdout, "<script>alert('xss')</script>")
if err != nil {
// Handle render failure. The error usually points to a missing field or type mismatch.
panic(err)
}
}
The output prints <p><script>alert('xss')</script></p>. The original string stays untouched. The escaping happens at render time, not at input time. This matters because the same string might need different escaping for HTML, JavaScript, CSS, or a URL. Context-aware tools handle that split automatically.
Context-aware rendering beats manual string scrubbing every time.
How the template engine actually works
When template.Parse runs, it builds an abstract syntax tree. It does not just store the string. It marks where data will be injected and records the surrounding context. When Execute runs, it walks that tree. It sees the {{.}} placeholder inside a <p> tag. It knows the context is HTML text. It converts < to < and > to > before writing.
If you place the same variable inside a href attribute, the engine switches to URL escaping. It converts spaces to %20 and quotes to %22. If you place it inside a <script> tag, it switches to JavaScript escaping. The engine tracks context as it traverses the template. You do not need to call separate functions for each output format.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow a template execution failure. If you forget to check the error, the compiler will not stop you, but your application will silently serve broken HTML or crash under load.
Trust the template engine. Do not write your own escaping functions.
Filesystem boundaries and path traversal
Web applications often need to serve files or read user-supplied paths. String manipulation is the wrong tool for filesystem security. Paths are not just text. They are handles that the kernel resolves against directories, symlinks, and mount points.
Here is how to validate a path and open a file safely.
package main
import (
"os"
"path/filepath"
)
func main() {
// Check if the path escapes the intended directory.
userPath := "../../etc/passwd"
if !filepath.IsLocal(userPath) {
// Reject traversal attempts before opening anything.
panic("invalid path")
}
// Open a root directory that blocks symlinks and .. traversal.
root, err := os.OpenRoot("/safe/uploads")
if err != nil {
panic(err)
}
defer root.Close()
// Open resolves relative to the root. Symlinks outside the root fail.
f, err := root.Open("report.pdf")
if err != nil {
panic(err)
}
defer f.Close()
}
filepath.IsLocal checks for absolute paths and .. segments. It catches obvious traversal attempts. os.OpenRoot, added in Go 1.22, goes further. It opens a directory and returns a handle that refuses to follow symlinks or escape the root. You pass the relative filename to root.Open. The kernel enforces the boundary. You do not need to manually strip .. or chase symlinks.
If you try to open a path that escapes the root, the runtime returns a path contains .. or link escapes root error. The compiler will not catch this at build time because it depends on runtime filesystem state. You must check the error and reject the request.
The filesystem is not a string. Treat paths as handles, not text.
Common pitfalls and compiler feedback
Developers often reach for strings.Replace or regular expressions to strip dangerous characters. This approach breaks on nested contexts and edge cases. The compiler will not stop you. It only checks types. If you pass a string to a function that expects a string, the code compiles. The security failure happens at runtime.
Another common mistake is ignoring template execution errors. If you write tmpl.Execute(w, data) without checking the return value, you get partial HTML or a silent crash. The compiler complains with template: comment:1:2: executing "comment" at <.>: can't evaluate field X in type main.User if you pass the wrong struct to the template. You must handle the error explicitly.
Path traversal via symlinks is harder to spot. filepath.Clean removes redundant separators and .. segments, but it does not resolve symlinks. An attacker can create a symlink inside your upload directory that points to /etc/shadow. os.Open follows it. os.OpenRoot blocks it. Always use the root handle when serving user files.
Public names start with a capital letter. Private names start lowercase. This applies to validation functions too. Export ValidateUsername if other packages need it. Keep sanitizeInternal lowercase if it only lives in your handler. The compiler enforces visibility at the package boundary.
The worst validation bug is the one that silently passes bad data to production.
When to use which tool
Use html/template when rendering HTML, XML, or email bodies for the browser. Use text/template when generating configuration files, logs, or CLI output that does not need HTML escaping. Use filepath.IsLocal combined with filepath.Clean when validating user-supplied paths before passing them to os.Open. Use os.OpenRoot when serving files from a fixed directory and you need kernel-enforced symlink protection. Use strict allowlists with regular expressions when accepting identifiers, usernames, or command flags. Use plain sequential validation before any I/O or rendering: reject bad input before it touches your database or template engine.
Pick the tool that matches the output context. Do not invent your own escaping rules.