Keep the SQL text constant and pass values as arguments: db.QueryContext(ctx, "... WHERE c.name = ? AND o.status = ?", customer, status). fmt.Sprintf into SQL is injection whether or not you escape quotes. Map the requested sort column and direction onto fixed SQL fragments with a map, and answer 400 for anything else.
- Find every Query, QueryContext, QueryRow, Exec and Prepare call whose SQL is built with fmt.Sprintf, +, strings.Builder or text/template from request data: r.URL.Query(), r.PathValue(), form values, decoded JSON and anything the data layer they call receives.
- Make the SQL a constant with placeholders and pass the values as the remaining arguments of QueryContext. The driver sends them apart from the SQL text, so a quote in a value stays a quote.
- For the sort column and direction, look the request value up in a map[string]string of fixed SQL fragments and return a sentinel error for anything else; the handler checks it with errors.Is and answers 400.
- Accept each query parameter once. r.URL.Query().Get returns the first of repeated values, so read the slice and reject a missing or repeated value with 400 before calling the database.
- Log database errors with slog on the server and answer a generic 500; never write a driver error's text into the response.
- Add regression tests with httptest.NewServer that send this card's payloads next to functional tests for names such as O'Brien and non-ASCII text.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
func (Vulnerable) OrdersForCustomer(ctx context.Context, db *sql.DB, customer, status string) ([]Order, error) {
query := fmt.Sprintf(`
SELECT o.id, o.status, o.total_cents, o.created_at
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE c.name = '%s' AND o.status = '%s'
ORDER BY o.id`, customer, status)
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
return scanOrders(rows, false)
}With IMPL=vulnerable, 7 of 11 tests fail (5 exploit tests).
The fix: placeholders and arguments
func (Fixed) OrdersForCustomer(ctx context.Context, db *sql.DB, customer, status string) ([]Order, error) {
const query = `
SELECT o.id, o.status, o.total_cents, o.created_at
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE c.name = ? AND o.status = ?
ORDER BY o.id`
rows, err := db.QueryContext(ctx, query, customer, status)
if err != nil {
return nil, err
}
return scanOrders(rows, false)
}With IMPL=fixed, 11 of 11 tests pass.
The fix: sort allowlist of SQL fragments
// A placeholder can only stand in for a value. Map the caller's choice onto
// fixed SQL fragments; anything else is ErrBadSort, which the handler turns into 400.
var (
sortColumns = map[string]string{
"created_at": "o.created_at",
"total_cents": "o.total_cents",
"customer": "c.name",
}
sortDirections = map[string]string{"asc": "ASC", "desc": "DESC"}
)
func (Fixed) SearchOrders(ctx context.Context, db *sql.DB, status, sort, direction string) ([]Order, error) {
column, okColumn := sortColumns[sort]
dir, okDir := sortDirections[direction]
if !okColumn || !okDir {
return nil, ErrBadSort
}
query := `
SELECT o.id, c.name AS customer, o.status, o.total_cents, o.created_at
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = ?
ORDER BY ` + column + ` ` + dir + `, o.id`
rows, err := db.QueryContext(ctx, query, status)
if err != nil {
return nil, err
}
return scanOrders(rows, true)
}With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
func escapeSQLString(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
return strings.ReplaceAll(value, `'`, `\'`)
}
func (Wrong) OrdersForCustomer(ctx context.Context, db *sql.DB, customer, status string) ([]Order, error) {
query := fmt.Sprintf(`
SELECT o.id, o.status, o.total_cents, o.created_at
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE c.name = '%s' AND o.status = '%s'
ORDER BY o.id`, escapeSQLString(customer), escapeSQLString(status))
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
return scanOrders(rows, false)
}With IMPL=wrong, 6 of 11 tests fail (4 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: each payload must stay data and leave the query's meaning unchanged.
func TestExploitTautologyDoesNotReturnOtherCustomersOrders(t *testing.T) {
srv := newServer(t)
expectIDs(t, orderIDs(t, srv, "/orders", url.Values{"customer": {"' OR '1'='1"}, "status": {"shipped"}}))
}
func TestExploitUnionCannotReadAnotherCustomersOrders(t *testing.T) {
srv := newServer(t)
customer := "' UNION SELECT id, status, total_cents, created_at FROM orders WHERE customer_id = 2 --"
expectIDs(t, orderIDs(t, srv, "/orders", url.Values{"customer": {customer}, "status": {"shipped"}}))
}
func TestExploitCommentCannotCutOffTheStatusFilter(t *testing.T) {
srv := newServer(t)
expectIDs(t, orderIDs(t, srv, "/orders", url.Values{"customer": {"Alice Martin'--"}, "status": {"shipped"}}))
}
func TestExploitSortExpressionIsRejectedNotUsedAsAnOracle(t *testing.T) {
srv := newServer(t)
// Row order would reveal whether each customer's hidden email starts with "b".
sort := "CASE WHEN substr(c.email, 1, 1) = char(98) THEN 0 ELSE 1 END"
res := get(t, srv, "/admin/orders", url.Values{"status": {"shipped"}, "sort": {sort}, "dir": {"asc"}})
if res.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400, got %d", res.StatusCode)
}
}
func TestExploitDirectionCannotAddASecondSortExpression(t *testing.T) {
srv := newServer(t)
dir := "ASC, CASE WHEN substr(c.email, 1, 1) = char(98) THEN 0 ELSE 1 END"
res := get(t, srv, "/admin/orders", url.Values{"status": {"shipped"}, "sort": {"created_at"}, "dir": {dir}})
if res.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400, got %d", res.StatusCode)
}
}- Placeholder syntax belongs to the driver, not to database/sql, which passes the SQL through unchanged: ? for modernc.org/sqlite and go-sql-driver/mysql, $1, $2 for pgx and lib/pq.
- fmt.Sprintf with %q is not SQL quoting. It writes a Go string literal: double quotes, which SQLite and standard SQL read as an identifier, and backslash escapes, which they do not understand.
- A placeholder can only stand in for a value: ORDER BY ? binds a constant and SQLite silently sorts by nothing. Identifiers need the allowlist, and the allowlist should hold only columns the caller may see, because sorting by a hidden column such as email still leaks its order.
- database/sql does not expand a slice into an IN list. Build one placeholder per value with strings.Repeat and pass the values as arguments; never join the values into the SQL.
- LIKE: a bound % still matches every row. Escape % and _ and add an ESCAPE clause when the caller's text should match literally.
- ORMs have the same hole: GORM documents Order, Group, Select, Raw, Joins and string conditions in Where as methods that put their string argument into the SQL without escaping.
- The allowlisted ORDER BY is still string concatenation, so gosql-sqli reports it. Keep the fragments in one map next to the query so a reviewer can see that only constants reach the SQL.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs sql-injection/go-nethttp does this for you, in Docker if you do not have Go).
- Run gosec ./... and semgrep --config r/go.lang.security.audit.database.string-formatted-query.string-formatted-query .; expect findings only in impl/vulnerable.go and impl/wrong.go.
- Against your own endpoint, send customer=' OR '1'='1 and expect an empty list, and send a CASE expression as the sort and expect 400.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | go.lang.security.audit.database.string-formatted-query.string-formatted-query | Flagged all four fmt.Sprintf queries: two in impl/vulnerable.go and two in impl/wrong.go; nothing in impl/fixed.go, whose ORDER BY is built with + from allowlisted fragments. It is a syntactic audit rule, so it cannot tell the escaping in wrong.go from no escaping, which here is correct.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> . |
| Semgrep | go.lang.security.audit.sqli.gosql-sqli.gosql-sqli | Five findings: the four QueryContext calls in impl/vulnerable.go and impl/wrong.go, and one in impl/fixed.go, where the ORDER BY clause is concatenated from the allowlist map. That last one is a false positive; review it and mark it with a nosemgrep comment rather than turning the rule off.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | go.lang.security.injection.tainted-sql-string.tainted-sql-string | No findings. The rule is marked interfile: its source is the *http.Request in app.go and the SQL is built in impl/, and Semgrep CE does not follow values across files. On a probe file with the handler and the Sprintf in one function it flagged the query.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | go/sql-injection | Four results: the two QueryContext calls in impl/vulnerable.go and the two in impl/wrong.go, each traced from r.URL in app.go's queryParam through the Repository interface. Nothing in impl/fixed.go: the sort column reaches the SQL only as a value looked up in the allowlist map, which the query does not treat as tainted.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165) |
| gosec | G201SQL query construction using format string | Flagged the four fmt.Sprintf queries in impl/vulnerable.go and impl/wrong.go (MEDIUM severity, HIGH confidence); nothing in impl/fixed.go.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./... |
| gosec | G202SQL query construction using string concatenation | No findings, including the allowlisted ORDER BY concatenation in impl/fixed.go.Checked: gosec 2.29.0 |
| gosec | G701SQL injection via taint analysis | No findings: the taint analysis does not follow the request value through the Repository interface into impl/. On a probe file with the handler and the Sprintf query in one function it reported G701 (HIGH severity, HIGH confidence).Checked: gosec 2.29.0; gosec -include=G701 ./... |
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http, database/sql) with modernc.org/sqlite 1.60.1 |
| modernc.org/sqlite | 1.60.1 (pure Go, no cgo; SQLite 3.53.4) |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the sql-injection/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs sql-injection/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- modernc.org/sqlite 1.60.1 (SQLite 3.53.4)
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- govulncheck 1.8.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.12
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 7 of 11 tests fail (5 exploit tests); wrong: 6 of 11 tests fail (4 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 7/11 fail (5 exploit tests); wrong 6/11 fail (4 exploit tests), with --network=none after install; the same on Go 1.26.9
- Dependency freshness
- modernc.org/sqlite 1.60.1 was published on 2026-09-29 and gotestsum 1.13.0 on 2025-09-11. gotestsum asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds (x/text/unicode/norm), so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release. govulncheck 1.8.0 found no vulnerabilities in the module or the standard library.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Go: Avoiding SQL injection risk
- Go: Querying for data (database/sql)
- Go standard library: database/sql DB.QueryContext
- Go standard library: net/http ServeMux patterns
- modernc.org/sqlite package documentation
- GORM: Security (SQL injection methods)
- OWASP SQL Injection Prevention Cheat Sheet
- CodeQL Go model of database/sql (query sinks)
- gosec 2.29.0: rule documentation
Attack patterns: