SQL injection from string-concatenated variables in pgx SQL statements

Critical Risk sql-injection
gogolangsql-injectionpgxpostgresqldatabase

What it is

SQL injection vulnerability in Go applications using the pgx PostgreSQL driver where SQL statements are built with string concatenation or fmt.Sprintf, allowing attackers to embed untrusted values directly into query text.

package main

import (
    "context"
    "fmt"
    "github.com/jackc/pgx/v4/pgxpool"
)

type User struct {
    ID     int
    Name   string
    Email  string
}

// VULNERABLE: String concatenation
func getUserByID(pool *pgxpool.Pool, userID string) (*User, error) {
    // DANGEROUS: Direct concatenation allows injection
    query := "SELECT id, name, email FROM users WHERE id = " + userID
    
    row := pool.QueryRow(context.Background(), query)
    
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    return &user, err
}

// VULNERABLE: fmt.Sprintf with user input
func searchUsers(pool *pgxpool.Pool, name, status string) ([]User, error) {
    // DANGEROUS: fmt.Sprintf builds SQL with user data
    query := fmt.Sprintf(
        "SELECT id, name, email FROM users WHERE name ILIKE '%%%s%%' AND status = '%s'",
        name, status
    )
    
    rows, err := pool.Query(context.Background(), query)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var users []User
    for rows.Next() {
        var user User
        rows.Scan(&user.ID, &user.Name, &user.Email)
        users = append(users, user)
    }
    return users, nil
}

/*
Attacks:
  userID = "1 OR 1=1 --" -> Returns all users
  name = "'; DROP TABLE users; --" -> Drops table
*/
package main

import (
    "context"
    "fmt"
    "strconv"
    "strings"
    "github.com/jackc/pgx/v4/pgxpool"
)

type User struct {
    ID     int
    Name   string
    Email  string
}

// SECURE: Parameterized query with $1 placeholder
func getUserByID(pool *pgxpool.Pool, userIDStr string) (*User, error) {
    // Validate input
    userID, err := strconv.Atoi(userIDStr)
    if err != nil {
        return nil, fmt.Errorf("invalid user ID")
    }
    
    // Use $1 placeholder - pgx handles escaping
    query := "SELECT id, name, email FROM users WHERE id = $1"
    row := pool.QueryRow(context.Background(), query, userID)
    
    var user User
    err = row.Scan(&user.ID, &user.Name, &user.Email)
    return &user, err
}

// SECURE: Parameterized query with $1, $2 placeholders
func searchUsers(pool *pgxpool.Pool, name, status string) ([]User, error) {
    // Validate inputs
    name = strings.TrimSpace(name)
    if len(name) > 100 {
        return nil, fmt.Errorf("name too long")
    }
    
    // Validate status against allowlist
    validStatuses := map[string]bool{"active": true, "inactive": true}
    if !validStatuses[status] {
        return nil, fmt.Errorf("invalid status")
    }
    
    // Use $1, $2 placeholders
    query := "SELECT id, name, email FROM users WHERE name ILIKE $1 AND status = $2 LIMIT 100"
    rows, err := pool.Query(context.Background(), query, "%"+name+"%", status)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var users []User
    for rows.Next() {
        var user User
        rows.Scan(&user.ID, &user.Name, &user.Email)
        users = append(users, user)
    }
    return users, nil
}

💡 Why This Fix Works

The vulnerable code uses string concatenation and fmt.Sprintf to build SQL queries, allowing SQL injection attacks where attackers can inject malicious SQL through user input (e.g., "1 OR 1=1 --" or "'; DROP TABLE users; --"). The secure version uses pgx parameterized queries with $1, $2, $3 placeholders and passes values as separate arguments to Query/Exec functions. This ensures user input is properly escaped and treated as data, not executable SQL. The secure code also implements input validation with allowlists and length checks as additional defense layers.

Why it happens

Go applications build SQL statements using string concatenation operators (+ or +=) to combine user input with SQL query fragments. This creates SQL injection vulnerabilities as special characters in user input aren't escaped, allowing attackers to modify query structure.

Root causes

String Concatenation for SQL Building

Go applications build SQL statements using string concatenation operators (+ or +=) to combine user input with SQL query fragments. This creates SQL injection vulnerabilities as special characters in user input aren't escaped, allowing attackers to modify query structure.

fmt.Sprintf for Query Formatting

Developers use fmt.Sprintf or similar string formatting functions to insert user-controlled values into SQL queries. Format strings like 'SELECT * FROM users WHERE id = %s' + userId directly embed untrusted data into SQL without proper parameterization.

Dynamic Query Building Without Parameterization

Applications dynamically construct SQL queries based on user input (table names, column names, filter conditions) without using pgx's parameterized query features. Dynamic queries built as strings enable SQL injection through crafted input.

Direct User Value Embedding

User-supplied values from HTTP requests, form data, or API parameters embedded directly into SQL query text as string literals. No separation exists between SQL structure and data values, allowing attackers to inject SQL commands.

Missing pgx Placeholder Parameters

Applications don't use pgx's PostgreSQL placeholder syntax ($1, $2, $3) for parameterized queries. Without placeholders, all values must be embedded in query strings, creating SQL injection vectors that pgx's parameter binding would prevent.

Fixes

1

Use Parameterized Queries with Placeholders

Replace string concatenation with pgx parameterized queries using PostgreSQL placeholder syntax ($1, $2, $3). Write queries like 'SELECT * FROM users WHERE username = $1' and pass user input as separate arguments: conn.Query(ctx, query, username). This ensures proper escaping and prevents SQL injection.

2

Pass Values as Separate Arguments

Always pass user-controlled values as separate arguments to pgx Query(), QueryRow(), and Exec() functions rather than embedding them in query strings. Use conn.Query(ctx, 'SELECT * FROM table WHERE id = $1', userID) instead of building the query with fmt.Sprintf.

3

Never Concatenate User Input into SQL

Completely eliminate string concatenation and formatting functions (fmt.Sprintf, +, strings.Join) for building SQL queries with user data. Treat any user input inclusion in SQL as requiring parameterized queries, with no exceptions for seemingly safe inputs.

4

Use Prepared Statements for Repeated Queries

For queries executed multiple times, use conn.Prepare() to create prepared statements with placeholders. Prepared statements are pre-compiled by PostgreSQL, improving performance and ensuring consistent parameterization: stmt, _ := conn.Prepare(ctx, 'get-user', 'SELECT * FROM users WHERE id = $1').

5

Implement Input Validation Alongside Parameterization

While parameterized queries prevent SQL injection, implement input validation to ensure data meets expected formats, lengths, and patterns. Validate identifiers, enforce type constraints, and reject obviously malicious patterns as defense-in-depth, even though parameters provide primary protection.

Detect This Vulnerability in Your Code

Sourcery automatically identifies sql injection from string-concatenated variables in pgx sql statements and many other security issues in your codebase.