CWE-89: SQL Injection - Go
Overview
SQL Injection in Go applications occurs when untrusted data is incorporated into SQL queries without proper parameterization. While Go's database/sql package provides built-in protection through prepared statements, developers can still create vulnerabilities by using string concatenation or formatting to build queries. The Go ecosystem includes several database libraries and ORMs (GORM, sqlx, sqlc) that provide varying levels of protection, but manual query construction remains a common source of vulnerabilities.
Go's type system and explicit error handling provide some natural defenses, but they don't prevent SQL injection when queries are built with string operations. The standard library's database/sql package is designed with security in mind, using placeholders (? for most databases, $1, $2... for PostgreSQL) that separate query structure from data. However, dynamic query construction for complex filters, sorting, or table names can bypass these protections if not implemented carefully.
Unlike some languages where ORMs dominate, Go applications often use raw SQL with the standard library, making it critical that developers understand parameterized queries. The language's simplicity means there are fewer "magic" protections, placing security responsibility squarely on the developer to use APIs correctly.
Primary Defence: Use parameterized queries with database/sql placeholders (? or $1), or leverage ORMs like GORM with proper query methods that auto-parameterize. Never concatenate user input into SQL strings.
Common Vulnerable Patterns
String Concatenation with Raw SQL
// VULNERABLE - String concatenation allows SQL injection
import (
"database/sql"
"fmt"
"net/http"
)
func getUserHandler(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("id")
// DANGEROUS: Direct string concatenation
query := "SELECT * FROM users WHERE id = " + userID
rows, err := db.Query(query)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
// ... process rows
}
// Attack example:
// GET /user?id=1 OR 1=1
// Query becomes: SELECT * FROM users WHERE id = 1 OR 1=1
// Result: Returns all users, bypassing authorization
Why this is vulnerable: String concatenation merges user input directly into the SQL command structure. Attackers can inject SQL operators (OR 1=1, UNION SELECT, ; DROP TABLE) that alter query logic, bypass authentication, extract data, or execute destructive operations.
fmt.Sprintf() Query Building
// VULNERABLE - String formatting is equally dangerous
func searchUsers(w http.ResponseWriter, r *http.Request) {
searchTerm := r.URL.Query().Get("name")
// DANGEROUS: fmt.Sprintf inserts unescaped user input
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", searchTerm)
rows, err := db.Query(query)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
// ... process rows
}
// Attack example:
// GET /search?name=admin' OR '1'='1
// Query becomes: SELECT * FROM users WHERE name = 'admin' OR '1'='1'
// Result: Returns all users
Why this is vulnerable: fmt.Sprintf() performs simple string substitution without escaping SQL metacharacters. Quotes, semicolons, and SQL keywords in user input become part of the query structure, enabling complete query manipulation including UNION attacks, comment injection, and stacked queries.
Dynamic Query Building Without Parameterization
// VULNERABLE - Building WHERE clauses dynamically
func filterUsers(w http.ResponseWriter, r *http.Request) {
var conditions []string
if name := r.URL.Query().Get("name"); name != "" {
// DANGEROUS: No parameterization
conditions = append(conditions, "name = '"+name+"'")
}
if email := r.URL.Query().Get("email"); email != "" {
// DANGEROUS: Same vulnerability
conditions = append(conditions, "email = '"+email+"'")
}
query := "SELECT * FROM users WHERE " + strings.Join(conditions, " AND ")
rows, err := db.Query(query)
// ... vulnerable query execution
}
// Attack example:
// GET /filter?name=admin'--
// Query becomes: SELECT * FROM users WHERE name = 'admin'--' AND email = ''
// Result: Comment removes rest of query, bypasses email filter
Why this is vulnerable: Dynamic WHERE clause construction with string concatenation allows injection in each condition. Attackers can use comment sequences (--, /**/) to truncate queries, inject OR conditions to bypass filters, or use UNION to append malicious SELECT statements.
String Concatenation in ORDER BY
// VULNERABLE - Dynamic sorting without validation
func listUsers(w http.ResponseWriter, r *http.Request) {
sortBy := r.URL.Query().Get("sort") // user controls: "name", "email", etc.
// DANGEROUS: Column name concatenation
query := "SELECT * FROM users ORDER BY " + sortBy
rows, err := db.Query(query)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
// ... process rows
}
// Attack example:
// GET /users?sort=name; DROP TABLE users--
// Query becomes: SELECT * FROM users ORDER BY name; DROP TABLE users--
// Result: Table deletion via SQL injection
Why this is vulnerable: Column names cannot be parameterized in SQL, forcing developers to concatenate identifiers. Without allowlist validation, attackers can inject arbitrary SQL including stacked queries (semicolon-separated statements), subqueries, or CASE expressions that trigger timing attacks for blind SQL injection.
Secure Patterns
Parameterized Queries with database/sql (Recommended)
// SECURE - Use placeholders for all user input
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
_ "github.com/lib/pq" // PostgreSQL driver
)
var db *sql.DB
func getUserByID(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("id")
// SECURE: Use placeholder ($1 for PostgreSQL, ? for MySQL/SQLite)
query := "SELECT id, name, email FROM users WHERE id = $1"
var user struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
err := db.QueryRow(query, userID).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Printf("Database error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
var err error
db, err = sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=require")
if err != nil {
log.Fatal(err)
}
defer db.Close()
http.HandleFunc("/user", getUserByID)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Why this works: Placeholder parameters ($1, $2 for PostgreSQL; ? for MySQL/SQLite) separate the query structure from data values. The database driver sends the query template and parameters separately via the wire protocol. Parameter values are treated as literals, not executable SQL, preventing attackers from injecting operators, keywords, or metacharacters. Even malicious strings like 1 OR 1=1 become literal search values, not logic conditions.
Multiple Parameters with Query()
// SECURE - Multiple placeholders for complex queries
import (
"database/sql"
"net/http"
)
func searchUsers(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
email := r.URL.Query().Get("email")
minAge := r.URL.Query().Get("min_age")
// SECURE: All user inputs are parameterized
query := `
SELECT id, name, email, age
FROM users
WHERE name LIKE $1
AND email LIKE $2
AND age >= $3
`
rows, err := db.Query(query, "%"+name+"%", "%"+email+"%", minAge)
if err != nil {
log.Printf("Query error: %v", err)
http.Error(w, "Search failed", http.StatusInternalServerError)
return
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
log.Printf("Scan error: %v", err)
continue
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
log.Printf("Rows error: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
Why this works: Each placeholder ($1, $2, $3) binds one parameter value, maintaining separation between query logic and data throughout multiple conditions. LIKE wildcards (%) are added in Go code before parameterization, making them part of the data value, not SQL syntax. The database processes parameters as typed literals, type-checking them according to column definitions, which provides additional validation beyond SQL injection prevention.
Dynamic Conditions with Parameterized Queries
// SECURE - Build dynamic WHERE clauses with proper parameterization
import (
"database/sql"
"net/http"
"strings"
)
func filterUsers(w http.ResponseWriter, r *http.Request) {
var conditions []string
var args []interface{}
paramCount := 1
if name := r.URL.Query().Get("name"); name != "" {
conditions = append(conditions, fmt.Sprintf("name = $%d", paramCount))
args = append(args, name)
paramCount++
}
if email := r.URL.Query().Get("email"); email != "" {
conditions = append(conditions, fmt.Sprintf("email = $%d", paramCount))
args = append(args, email)
paramCount++
}
if role := r.URL.Query().Get("role"); role != "" {
conditions = append(conditions, fmt.Sprintf("role = $%d", paramCount))
args = append(args, role)
paramCount++
}
// Default condition if no filters provided
if len(conditions) == 0 {
conditions = append(conditions, "1=1")
}
// SECURE: Parameterized WHERE clause
query := "SELECT id, name, email FROM users WHERE " + strings.Join(conditions, " AND ")
rows, err := db.Query(query, args...)
if err != nil {
log.Printf("Query error: %v", err)
http.Error(w, "Filter failed", http.StatusInternalServerError)
return
}
defer rows.Close()
// Process results...
}
Why this works: The query structure (WHERE clause with placeholders $1, $2, $3) is built from trusted code, while all user-supplied data goes into the args slice for parameterization. By incrementing paramCount and using fmt.Sprintf only for placeholder numbers (not user data), the dynamic query remains safe. The args... variadic expansion passes parameters to db.Query() in order, maintaining the parameterization contract even with conditional logic.
Allowlist Validation for Identifiers (ORDER BY, Column Names)
// SECURE - Allowlist validation for non-parameterizable elements
import (
"database/sql"
"fmt"
"net/http"
)
var allowedSortColumns = map[string]bool{
"id": true,
"name": true,
"email": true,
"created_at": true,
}
var allowedSortOrders = map[string]bool{
"ASC": true,
"DESC": true,
}
func listUsersSorted(w http.ResponseWriter, r *http.Request) {
sortBy := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
// Default values
if sortBy == "" {
sortBy = "id"
}
if order == "" {
order = "ASC"
}
// SECURE: Allowlist validation (identifiers cannot be parameterized)
if !allowedSortColumns[sortBy] {
http.Error(w, "Invalid sort column", http.StatusBadRequest)
return
}
if !allowedSortOrders[order] {
http.Error(w, "Invalid sort order", http.StatusBadRequest)
return
}
// SAFE: Validated identifiers + parameterized search
searchTerm := r.URL.Query().Get("search")
query := fmt.Sprintf(
"SELECT id, name, email FROM users WHERE name LIKE $1 ORDER BY %s %s",
sortBy, // validated against allowlist
order, // validated against allowlist
)
rows, err := db.Query(query, "%"+searchTerm+"%")
if err != nil {
log.Printf("Query error: %v", err)
http.Error(w, "Query failed", http.StatusInternalServerError)
return
}
defer rows.Close()
// Process results...
}
Why this works: SQL identifiers (table names, column names, ORDER BY, keywords) cannot be parameterized because they define query structure, not data values. Allowlist validation ensures only pre-approved identifiers reach the query, blocking injection attempts. The map lookup is O(1) and type-safe, rejecting any attacker-controlled SQL keywords, semicolons, or operators. This pattern safely combines parameterization (for data) with allowlisting (for identifiers), addressing both attack surfaces.
Framework-Specific Guidance
GORM (Popular ORM)
// SECURE - GORM with proper query methods
package main
import (
"encoding/json"
"log"
"net/http"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primarykey"`
Name string
Email string
Age int
}
var gormDB *gorm.DB
func searchUsersGORM(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
minAge := r.URL.Query().Get("min_age")
var users []User
// SECURE: GORM auto-parameterizes with Where()
query := gormDB.Where("name LIKE ?", "%"+name+"%")
if minAge != "" {
query = query.Where("age >= ?", minAge)
}
result := query.Find(&users)
if result.Error != nil {
log.Printf("GORM error: %v", result.Error)
http.Error(w, "Search failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
// VULNERABLE - Raw SQL without parameterization in GORM
func vulnerableGORM(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("id")
var user User
// DANGEROUS: db.Raw with string concatenation
query := "SELECT * FROM users WHERE id = " + userID
gormDB.Raw(query).Scan(&user) // VULNERABLE
}
// SECURE - Raw SQL with parameters in GORM
func secureGORMRaw(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("id")
var user User
// SECURE: db.Raw with placeholders
gormDB.Raw("SELECT * FROM users WHERE id = ?", userID).Scan(&user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
var err error
dsn := "host=localhost user=postgres password=pass dbname=testdb port=5432 sslmode=require"
gormDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
gormDB.AutoMigrate(&User{})
http.HandleFunc("/search", searchUsersGORM)
http.HandleFunc("/user/raw", secureGORMRaw)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Why this works: GORM's Where(), Find(), First(), and other query builder methods automatically parameterize values using placeholders internally. When chaining conditions, each Where() call adds a parameterized clause to the query. GORM converts struct fields and method arguments to parameters, preventing SQL injection. However, db.Raw() requires explicit placeholders (?) - without them, it's as vulnerable as raw string concatenation. Using GORM's query builder methods is the safest approach as they handle parameterization transparently.
sqlx (Enhanced database/sql)
// SECURE - sqlx with named parameters
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
}
var sqlxDB *sqlx.DB
func getUsersSqlx(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
minAge := r.URL.Query().Get("min_age")
// SECURE: Named parameters with sqlx
query := `
SELECT id, name, email
FROM users
WHERE name LIKE :name
AND age >= :min_age
`
params := map[string]interface{}{
"name": "%" + name + "%",
"min_age": minAge,
}
rows, err := sqlxDB.NamedQuery(query, params)
if err != nil {
log.Printf("Query error: %v", err)
http.Error(w, "Query failed", http.StatusInternalServerError)
return
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.StructScan(&u); err != nil {
log.Printf("Scan error: %v", err)
continue
}
users = append(users, u)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
// SECURE: sqlx Get() with struct scanning
func getUserByEmailSqlx(w http.ResponseWriter, r *http.Request) {
type SearchParams struct {
Email string `db:"email"`
}
params := SearchParams{
Email: r.URL.Query().Get("email"),
}
var user User
// SECURE: NamedQuery with struct
query := "SELECT id, name, email FROM users WHERE email = :email"
err := sqlxDB.Get(&user, sqlxDB.Rebind(query), params.Email)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Printf("Query error: %v", err)
http.Error(w, "Query failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
var err error
sqlxDB, err = sqlx.Connect("postgres", "postgres://user:pass@localhost/dbname?sslmode=require")
if err != nil {
log.Fatal(err)
}
defer sqlxDB.Close()
http.HandleFunc("/users", getUsersSqlx)
http.HandleFunc("/user/email", getUserByEmailSqlx)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Why this works: sqlx extends database/sql with named parameters (:name) and struct mapping, making parameterized queries more readable and maintainable. NamedQuery() converts named placeholders to positional placeholders and binds map/struct values as parameters, maintaining the same SQL injection protection as standard database/sql. The StructScan() method maps database columns to struct fields using tags, reducing boilerplate while preserving security. Named parameters make it harder to mismatch parameter order, reducing bugs and security mistakes.
Chi Router with Middleware
// SECURE - Chi router with SQL injection protection
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/lib/pq"
)
var db *sql.DB
// User represents a database user
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// getUserHandler retrieves a user by ID from URL parameter
func getUserHandler(w http.ResponseWriter, r *http.Request) {
userIDStr := chi.URLParam(r, "userID")
// Convert and validate
userID, err := strconv.Atoi(userIDStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// SECURE: Parameterized query
var user User
query := "SELECT id, name, email FROM users WHERE id = $1"
err = db.QueryRow(query, userID).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Printf("Database error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
var err error
db, err = sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=require")
if err != nil {
log.Fatal(err)
}
defer db.Close()
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
// Routes with parameterized queries
r.Get("/users/{userID}", getUserHandler)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
Why this works: Chi's URL parameter extraction (chi.URLParam) combined with type validation (strconv.Atoi) and parameterized queries provides defense-in-depth. Type conversion rejects non-numeric input early, while parameterization ensures even numeric-looking injection attempts (1 OR 1=1) are treated as literals. Chi middleware (logging, recovery) aids debugging without exposing sensitive data, and the explicit routing pattern makes code review easier, helping identify query construction points that need parameterization.
Input Validation (Defense in Depth)
// SECURE - Input validation alongside parameterization
import (
"database/sql"
"net/http"
"regexp"
"strconv"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
func validateAndQueryUser(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
ageStr := r.URL.Query().Get("age")
// Validation layer (defense in depth)
if !emailRegex.MatchString(email) {
http.Error(w, "Invalid email format", http.StatusBadRequest)
return
}
age, err := strconv.Atoi(ageStr)
if err != nil || age < 0 || age > 150 {
http.Error(w, "Invalid age", http.StatusBadRequest)
return
}
// Still use parameterization (primary defense)
query := "SELECT id, name, email FROM users WHERE email = $1 AND age = $2"
var user User
err = db.QueryRow(query, email, age).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
http.Error(w, "Query failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}