FlawOpen

flawopen.com/sql-injection/Go

● CWE-89 · Critical
FlawOpen Security Research

SQL Injection in Go (database/sql, pgx, GORM)

fmt.Sprintf or + inside db.Query() lets a request parameter rewrite your SQL, turning a single-row lookup into a full table dump (CWE-89). The safe call for database/sql, pgx and GORM, the placeholder each driver expects, and how to handle ORDER BY columns.[1], [2], [3], [4], [6], [7]

💡 Plain English Explainer (ELI5)

Imagine sending a train manifest as two separate items: a fixed route sheet and sealed cargo tags for the passenger’s values. Go db.QueryRowContext(ctx, sql, args...) keeps the SQL and args separate; the driver fills the placeholder, such as $1 for PostgreSQL or ? for MySQL and SQLite. fmt.Sprintf writes the passenger’s words into the route sheet before the driver sees it, so they become SQL instructions. pgx and GORM Where can bind values too, but GORM Order takes raw SQL; map the sort key to an allowlisted clause.OrderByColumn instead.[2], [1], [3], [4], [12]

Core Concepts & Subsystem Terms

db.QueryRowContext(ctx, sql, args...)
database/sql sends everything after the SQL string as separate arguments. It is safe only when the values are in args; a query built with fmt.Sprintf or + has already mixed data into the SQL before the driver sees it.[2], [11]
Placeholders: $1 vs ?
database/sql does not rewrite placeholders; the driver does. pgx and lib/pq expect $1, $2; go-sql-driver/mysql and the SQLite drivers expect ?. A wrong style fails loudly, which is far better than falling back to Sprintf.[2], [12], [13]
pgx and pgx.NamedArgs
pgx sends the values as typed parameters of a prepared statement, so PostgreSQL parses the SQL before any value arrives. pgx.NamedArgs adds @name placeholders for queries with many values.[3]
GORM Where / Raw / Order
db.Where("email = ?", email) and db.Raw(sql, args...) bind values. GORM's security guide warns that strings given to Order, Select, Group or a placeholder-free Where are inserted as raw SQL.[4], [6]
clause.OrderByColumn
Column names cannot be bound as parameters. Map the requested key through an allowlist and pass the result as clause.OrderByColumn, which GORM quotes as an identifier.[4], [14]

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches the handler

An http.HandlerFunc reads r.URL.Query().Get("id") and passes the string to GetEmail(). Because it stays a string, no strconv.ParseInt check ever runs.[8], [9], [10]

Step 2

fmt.Sprintf builds the SQL

fmt.Sprintf("... WHERE id = %s", userID) formats the value into the text. With the benign test value 0 OR 1=1, the query becomes WHERE id = 0 OR 1=1.[2], [1], [6]

Step 3

The driver gets SQL and no arguments

QueryRowContext(ctx, q) has nothing to bind, so PostgreSQL parses OR 1=1 as part of the WHERE clause rather than as a value.[2], [11], [3]

Step 4

Rows the caller should not see come back

The condition matches every row. With Query instead of QueryRow the handler returns the whole table; in GORM the same happens through a formatted Where or an Order taken from the query string.[1], [4], [6]

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// store.go: each query below formats request data into the SQL text
package store

import (
	"context"
	"database/sql"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
	"gorm.io/gorm"
)

type User struct {
	ID    int64
	Email string
}

// database/sql: Sprintf merges the value into the SQL before the driver sees it
func GetEmail(ctx context.Context, db *sql.DB, userID string) (string, error) {
	var email string
	q := fmt.Sprintf("SELECT email FROM users WHERE id = %s", userID)
	err := db.QueryRowContext(ctx, q).Scan(&email)
	return email, err
}

// pgx: string concatenation, the same mistake with a different operator
func CountOrders(ctx context.Context, pool *pgxpool.Pool, status string) (int64, error) {
	var n int64
	err := pool.QueryRow(ctx, "SELECT count(*) FROM orders WHERE status = '"+status+"'").Scan(&n)
	return n, err
}

// GORM: a formatted Where condition is inserted as raw SQL
func FindByEmail(db *gorm.DB, email string) (User, error) {
	var u User
	err := db.Where(fmt.Sprintf("email = '%s'", email)).First(&u).Error
	return u, err
}

// Order() takes raw SQL, here straight from the query string
func ListUsers(db *gorm.DB, sortColumn string) ([]User, error) {
	var users []User
	err := db.Order(sortColumn).Find(&users).Error
	return users, err
}
HARDENED SECURE PATCH
// store.go: values are bound as arguments, columns come from an allowlist
package store

import (
	"context"
	"database/sql"

	"github.com/jackc/pgx/v5/pgxpool"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

type User struct {
	ID    int64
	Email string
}

var sortableColumns = map[string]string{"id": "id", "email": "email", "created": "created_at"}

// database/sql: $1 placeholder, value passed as a separate argument
func GetEmail(ctx context.Context, db *sql.DB, userID int64) (string, error) {
	var email string
	err := db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email)
	return email, err
}

// pgx: the value travels as a typed parameter of a prepared statement
func CountOrders(ctx context.Context, pool *pgxpool.Pool, status string) (int64, error) {
	var n int64
	err := pool.QueryRow(ctx, "SELECT count(*) FROM orders WHERE status = $1", status).Scan(&n)
	return n, err
}

// GORM: ? placeholder, value bound by the driver
func FindByEmail(db *gorm.DB, email string) (User, error) {
	var u User
	err := db.Where("email = ?", email).First(&u).Error
	return u, err
}

// allowlist lookup, then GORM quotes the column as an identifier
func ListUsers(db *gorm.DB, sortKey string) ([]User, error) {
	column, ok := sortableColumns[sortKey]
	if !ok {
		column = "id"
	}
	var users []User
	err := db.Order(clause.OrderByColumn{Column: clause.Column{Name: column}}).Find(&users).Error
	return users, err
}

Engineering & System Hardening Checklist

Sources