Prevention — Go
database/sql placeholders, why fmt.Sprintf into a query is the whole bug, and GORM's Raw vs interpolation.
The Rule
Pass values as variadic arguments to database/sql methods. The placeholder character is driver-specific — ? for MySQL and SQLite, $1 for PostgreSQL, @p1 or :name for MSSQL — but the principle is identical: the query string is constant, the values travel separately.
Go's trap is fmt.Sprintf. It is the idiomatic way to build strings in Go, which makes it the idiomatic way to write a SQL injection. Any Sprintf, +, or strings.Builder producing a query with user data in it is the vulnerability.
database/sql
// fmt.Sprintf builds the final string before the driver sees it.
q := fmt.Sprintf(
"SELECT * FROM users WHERE name = '%s'", name)
rows, err := db.Query(q)
// Concatenation, identical problem
rows, err := db.Query(
"SELECT * FROM users WHERE id = " + id)GORM
// Sprintf into Raw or Where — injectable.
db.Raw(fmt.Sprintf(
"SELECT * FROM users WHERE name = '%s'", name)).Scan(&users)
db.Where(fmt.Sprintf("name = '%s'", name)).Find(&users)
// Order with raw input (identifier context)
db.Order(sortParam).Find(&products)sqlc and Compile-Time Safety
Go has a strong option unavailable in most languages: sqlc generates type-safe Go from SQL you write in .sql files, with parameters as named arguments. Because the queries are fixed at build time and parameters are always bound, there is no string-building surface for injection to exist in.
-- name: GetUserByName :one
SELECT * FROM users WHERE name = $1;
generates a GetUserByName(ctx, name) function that is parameterised by construction. Where the query set is known ahead of time, this eliminates the class rather than guarding against it.
pgx (used directly rather than through database/sql) similarly enforces parameterisation for its Query/Exec methods and is a good default for PostgreSQL.
Checklist
- Values as variadic args to
Query/QueryRow/Exec. Neverfmt.Sprintf,+, orstrings.Builderproducing SQL with user data. - Match the driver's placeholder style (
?,$1,@p1). - GORM:
?args inRaw/Where; struct/map conditions; allowlist forOrder. - Consider
sqlcorpgxto make parameterisation structural. - Identifiers: allowlist.
go vetdoes not catch this; add a linter such asgolangci-lintwith an SQL-injection analyzer, or Semgrep, to CI.- Least-privilege database role — see Defense in Depth.
Related
Every ORM has an escape hatch to raw SQL, and every one of them is a footgun. Where the abstraction stops protecting you, and which method names to grep for.
Strict typing, stacked queries, and COPY TO PROGRAM — the engine where an injection most reliably becomes command execution.
Where your input lands in the query determines which payloads can possibly work. Get the context wrong and every payload fails for the wrong reason.
Fingerprinting, syntax, metadata access, file operations and command execution on the most commonly encountered engine.
Parameterisation removes the bug; these layers limit the blast radius when a parameterisation is missed. Least privilege, allowlists, monitoring, and what each is actually worth.