🎭 Analogy
Injection is a con artist slipping a forged instruction into a stack of paperwork you’ll act on without reading. “Name: Robert’); DROP TABLE students;” only works because the clerk pastes the name straight into a command. The fix isn’t to scan for suspicious names — it’s to never let the data into the instruction slot at all: hand the form and the values to the processor separately, so a value can never become a command. Every injection defense is a version of that one move.
One bug, many names
SQL injection, command injection, XSS, LDAP injection, path traversal, header injection — they’re the same vulnerability wearing different clothes: untrusted data crosses into a context where it’s interpreted as code or control.
graph TD
U["untrusted input"] --> MIX{"mixed into…"}
MIX -->|SQL string| SQLI["SQL injection"]
MIX -->|shell command| CMDI["command injection"]
MIX -->|HTML markup| XSS["cross-site scripting"]
MIX -->|file path| TRAV["path traversal"]
SQLI --> FIX["FIX: separate data from code"]
CMDI --> FIX
XSS --> FIX
TRAV --> FIXThe cure is structural and the same everywhere: keep data out of the code channel. Parameterized queries for SQL, argument arrays for commands, context-aware escaping for HTML, validated paths for files.
SQL injection → parameterized queries
Never build SQL by concatenation. database/sql placeholders send the query and the values on separate channels, so a value can’t change the query:
// ❌ VULNERABLE: user input becomes part of the SQL.
q := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name) // name = "' OR '1'='1"
db.Query(q)
// ✅ SAFE: the driver sends ? and the value separately; the value is never parsed as SQL.
db.Query("SELECT * FROM users WHERE name = ?", name)
That’s the whole fix — the ? placeholder. No escaping, no blocklist. (See database/sql.)
Command injection → no shell, argument arrays
// ❌ VULNERABLE: a shell parses the whole string. input = "x; rm -rf /"
exec.Command("sh", "-c", "ping "+input).Run()
// ✅ SAFE: no shell; the argument is one opaque element, never re-parsed.
exec.Command("ping", "-c", "1", input).Run()
os/exec doesn’t use a shell unless you invoke one — pass arguments as separate elements and ; | $() are just literal characters. (See processes & exec.)
See it: XSS defense with html/template
html/template is context-aware — it escapes data correctly for wherever it lands. This runs here: the <script> payload comes out as inert text, not executable markup:
package main
import (
"html/template"
"os"
)
func main() {
// html/template knows {{.}} is in an HTML body and escapes accordingly.
page := template.Must(template.New("p").Parse("<p>Comment: {{.}}</p>"))
// A malicious comment a user submitted.
evil := "<script>steal(document.cookie)</script>"
// The script tags are neutralized into harmless text — XSS defused.
page.Execute(os.Stdout, evil)
}
The output escapes <script> to <script>, so the browser renders it as text instead of running it. text/template would emit it raw — an XSS hole. Always render HTML with html/template.
See it: safe path handling
User-supplied filenames invite path traversal (../../etc/passwd). The fix is to clean the joined path and confirm it stays under your base directory. This runs here:
package main
import (
"fmt"
"path/filepath"
"strings"
)
// safeJoin returns base+name only if the result stays inside base.
func safeJoin(base, name string) (string, bool) {
full := filepath.Clean(filepath.Join(base, name))
cleanBase := filepath.Clean(base)
if full != cleanBase && !strings.HasPrefix(full, cleanBase+string(filepath.Separator)) {
return "", false
}
return full, true
}
func main() {
base := "/srv/files"
for _, name := range []string{"report.pdf", "sub/data.csv", "../../etc/passwd", "../secret"} {
if p, ok := safeJoin(base, name); ok {
fmt.Printf("%-18s -> serve %s\n", name, p)
} else {
fmt.Printf("%-18s -> REJECT (escapes base)\n", name)
}
}
}
The ../ payloads are rejected because the cleaned path no longer lives under /srv/files. Even better when you can: don’t take a path from the user at all — map an ID to a server-side path.
Validate with an allowlist
Beyond the injection-specific fixes, validate every input at the boundary with an allowlist: define exactly what’s valid (type, length, charset, range, enum) and reject the rest. Blocklists (“strip <script>”) always lose to an encoding you didn’t think of.
🐹 Defense in depth: validate, parameterize, escape, least-privilege
No single layer is enough — stack them. Validate input with an allowlist at the edge. Parameterize every query and use argument arrays for commands. Escape output for its context with html/template. And run with least privilege (a DB user that can’t DROP, a process that can’t write outside its dir) so even a missed bug has a small blast radius. The fuzzer finds the inputs you forgot to validate; the parameterized query makes them harmless anyway.
⚠️ 'Sanitizing' by stripping bad characters is a trap
The instinct to “clean” input by deleting or escaping dangerous characters yourself is how injection bugs survive. Attackers bypass it with double-encoding (%252e%252e), alternate representations (’ for a quote), nested patterns (…/ that collapses to ../), or a context you didn’t handle. The robust answer is never to filter the data but to change the channel: parameterized queries, argument arrays, context-aware templates. Treat input as opaque values, not strings to scrub.
See also
- Fuzzing for bugs — find the unvalidated boundaries automatically.
- Web security (web track) — XSS, CSRF, security headers in depth.
- database/sql & transactions — parameterized queries in practice.
- Authentication & authorization — the next defensive layer.
Next: deciding who someone is and what they may do — authentication & authorization.
Related topics
Finding crashes and vulnerabilities by feeding malformed input — a runnable mutation fuzzer that discovers a parser bug, Go's built-in coverage-guided fuzzing, and why fuzzing your own code is the best defense.
defenseAuthentication & AuthorizationProving who you are and deciding what you may do — sessions vs tokens, secure token generation and constant-time checks, password verification, and least-privilege authorization (RBAC).
defenseSecrets ManagementKeeping API keys, passwords, and signing keys out of your code, repo, logs, and binary — config from the environment, secret managers, redaction, and rotation.
Check your understanding
Score: 0 / 51. What is the root cause shared by SQL injection, command injection, and XSS?
Every injection is the same bug: data crosses into a context where it's interpreted as instructions. A username becomes part of a SQL statement, a filename becomes part of a shell command, a comment becomes part of HTML. The structural fix is to keep data and code separate — parameterized queries, argument arrays, and context-aware escaping — never string concatenation.
2. What is the correct defense against SQL injection in Go?
Parameterized (prepared) queries with placeholders (? or $1) send the query structure and the parameter values over separate channels, so the value can never change the query's meaning. Manual quote-escaping is fragile and bypassable; blocklisting keywords breaks legitimate input and misses encodings. database/sql parameterizes by default — just never fmt.Sprintf user data into SQL.
3. Why use html/template instead of text/template (or string concatenation) to render web pages?
html/template understands where each value lands — HTML body, an attribute, a <script>, a URL — and applies the right escaping for that context, so <script>alert(1)</script> becomes inert text. text/template does no escaping (XSS waiting to happen), and manual escaping inevitably misses a context. Always render HTML with html/template.
4. What is the right way to validate untrusted input — allowlist or blocklist?
Blocklists are a losing game: attackers find encodings, cases, and patterns you didn't list. An allowlist defines the small set of valid inputs (e.g. 'a UUID', 'an int 1–100', 'these enum values') and rejects everything else — secure by default. Validate type, length, format, and range as early as possible, and re-validate at trust boundaries.
5. How do you safely build a file path from a user-supplied name (avoid path traversal)?
Path traversal (../../etc/passwd) escapes the intended directory. The fix: filepath.Join then filepath.Clean the result and confirm it's still prefixed by the cleaned base directory; reject otherwise. Naive concatenation or single-pass ../ stripping is bypassable (....// collapses to ../). Better still, map user input to an ID and look up the real path server-side.
Comments
Sign in with GitHub to join the discussion.