Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix IDOR and missing authorization in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Build the signed-in user in middleware from the verified session and the users table, never from request data, and put the same scope in every query's WHERE clause: tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin). Answer 404 for anything outside it, and refuse an export when any requested ID is out of scope.

Stack
  • 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
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 26 KB
On this page

Steps

  1. Find every handler that takes an object ID (r.PathValue, the query string, a JSON body) and every query it runs: a lookup by primary key, the list queries and bulk queries over a list of IDs.
  2. Write one authentication middleware that verifies the session or token, loads the user's tenant and role from the users table, answers 401 when there is no user, and stores the user in the request context under an unexported key type. Handlers read the user from there and nowhere else.
  3. Write the visibility rule once, as a SQL fragment with named parameters (sql.Named), and add it to the WHERE clause of the detail, list and export queries.
  4. Return 404 for objects outside the caller's scope, the same answer as for missing ones, and refuse a bulk request entirely when any ID is out of scope.
  5. Test through httptest.NewServer with a stub authenticator that still goes through the middleware, and with users in two tenants, including a billing admin of the other tenant.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.goGo
func (Vulnerable) GetInvoice(ctx context.Context, db *sql.DB, user User, id int64) (Invoice, error) {
	return getOne(db.QueryRowContext(ctx, `SELECT `+columns+` FROM invoices WHERE id = ?`, id))
}

func (Vulnerable) ListInvoices(ctx context.Context, db *sql.DB, user User) ([]Invoice, error) {
	return scanInvoices(db.QueryContext(ctx,
		`SELECT `+columns+` FROM invoices WHERE tenant_id = ? ORDER BY id`, user.TenantID))
}

func (Vulnerable) ExportInvoices(ctx context.Context, db *sql.DB, user User, ids []int64) ([]Invoice, error) {
	return scanInvoices(db.QueryContext(ctx,
		`SELECT `+columns+` FROM invoices WHERE id IN (SELECT value FROM json_each(?)) ORDER BY id`, idsJSON(ids)))
}

With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).

The fix

Fixedimpl/fixed.goGo
// Members read their own invoices; billing admins read every invoice in their own tenant.
// The tenant test sits outside the parentheses, so it binds admins too.
const visibleTo = `tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin)`

func scope(user User) []any {
	return []any{
		sql.Named("tenant", user.TenantID),
		sql.Named("user", user.ID),
		sql.Named("is_billing_admin", user.Role == "billing_admin"),
	}
}

func (Fixed) GetInvoice(ctx context.Context, db *sql.DB, user User, id int64) (Invoice, error) {
	return getOne(db.QueryRowContext(ctx,
		`SELECT `+columns+` FROM invoices WHERE id = :id AND `+visibleTo,
		append(scope(user), sql.Named("id", id))...))
}

func (Fixed) ListInvoices(ctx context.Context, db *sql.DB, user User) ([]Invoice, error) {
	return scanInvoices(db.QueryContext(ctx,
		`SELECT `+columns+` FROM invoices WHERE `+visibleTo+` ORDER BY id`, scope(user)...))
}

func (Fixed) ExportInvoices(ctx context.Context, db *sql.DB, user User, ids []int64) ([]Invoice, error) {
	return scanInvoices(db.QueryContext(ctx,
		`SELECT `+columns+` FROM invoices WHERE id IN (SELECT value FROM json_each(:ids)) AND `+visibleTo+` ORDER BY id`,
		append(scope(user), sql.Named("ids", idsJSON(ids)))...))
}

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
func canRead(user User, inv Invoice) bool {
	return inv.OwnerID == user.ID || user.Role == "billing_admin"
}

func (Wrong) GetInvoice(ctx context.Context, db *sql.DB, user User, id int64) (Invoice, error) {
	inv, err := getOne(db.QueryRowContext(ctx, `SELECT `+columns+` FROM invoices WHERE id = ?`, id))
	if err == nil && !canRead(user, inv) {
		return Invoice{}, ErrNotFound
	}
	return inv, err
}

func (Wrong) ListInvoices(ctx context.Context, db *sql.DB, user User) ([]Invoice, error) {
	all, err := scanInvoices(db.QueryContext(ctx, `SELECT `+columns+` FROM invoices ORDER BY id`))
	return readable(user, all), err
}

func (Wrong) ExportInvoices(ctx context.Context, db *sql.DB, user User, ids []int64) ([]Invoice, error) {
	found, err := scanInvoices(db.QueryContext(ctx,
		`SELECT `+columns+` FROM invoices WHERE id IN (SELECT value FROM json_each(?)) ORDER BY id`, idsJSON(ids)))
	return readable(user, found), err
}

func readable(user User, invoices []Invoice) []Invoice {
	kept := []Invoice{}
	for _, inv := range invoices {
		if canRead(user, inv) {
			kept = append(kept, inv)
		}
	}
	return kept
}

With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).

Routes: the user comes from authentication, not the request

app.goGo
// The handlers hand currentUser(r) to the repository and never read a user, tenant or role
// from the request. ServeMux picks the most specific pattern, so /invoices/export wins over
// /invoices/{id} whatever the order of registration.
mux.HandleFunc("GET /invoices", func(w http.ResponseWriter, r *http.Request) {
	invoices, err := repo.ListInvoices(r.Context(), db, currentUser(r))
	if err != nil {
		serverError(w, r, log, err)
		return
	}
	writeJSON(w, http.StatusOK, invoices)
})

mux.HandleFunc("GET /invoices/export", func(w http.ResponseWriter, r *http.Request) {
	ids, ok := parseIDs(r.URL.Query()["ids"])
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": fmt.Sprintf("ids must be 1 to %d invoice IDs, comma-separated", MaxExportIDs)})
		return
	}
	invoices, err := repo.ExportInvoices(r.Context(), db, currentUser(r), ids)
	if err != nil {
		serverError(w, r, log, err)
		return
	}
	// All or nothing: one missing or unreadable ID refuses the whole export, so a partial
	// file is never mistaken for the full one.
	if len(invoices) != len(ids) {
		notFound(w)
		return
	}
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	writeCSV(w, invoices)
})

mux.HandleFunc("GET /invoices/{id}", func(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(r.PathValue("id"))
	if !ok {
		notFound(w)
		return
	}
	invoice, err := repo.GetInvoice(r.Context(), db, currentUser(r), id)
	switch {
	case errors.Is(err, impl.ErrNotFound):
		notFound(w) // missing and not yours get the same answer, so IDs cannot be probed
	case err != nil:
		serverError(w, r, log, err)
	default:
		writeJSON(w, http.StatusOK, invoice)
	}
})

The regression test

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.

Testapp_test.goGo
// Exploit: other users, other tenants and other tenants' admins must get nothing.

// expectHidden requires that denied looks exactly like missing: 404 and none of the invoice's data.
func expectHidden(t *testing.T, user, path, number string) {
	t.Helper()
	res := get(t, newServer(t), user, path)
	if res.status != http.StatusNotFound {
		t.Errorf("expected 404, got %d: %s", res.status, res.body)
	}
	if strings.Contains(res.body, number) {
		t.Errorf("response leaked %s: %s", number, res.body)
	}
}

func TestExploitMemberCannotReadAColleaguesInvoiceInTheSameTenant(t *testing.T) {
	expectHidden(t, "alice", "/invoices/502", "INV-502")
}

func TestExploitMemberOfAnotherTenantCannotReadTheInvoice(t *testing.T) {
	expectHidden(t, "erin", "/invoices/501", "INV-501")
}

func TestExploitBillingAdminCannotReadAnInvoiceInAnotherTenant(t *testing.T) {
	expectHidden(t, "cara", "/invoices/601", "INV-601")
}

func TestExploitMembersListLeavesOutColleaguesInvoices(t *testing.T) {
	expectIDs(t, listedIDs(t, newServer(t), "alice"), 501, 503)
}

func TestExploitBillingAdminsListStaysInsideTheirTenant(t *testing.T) {
	expectIDs(t, listedIDs(t, newServer(t), "cara"), 501, 502, 503)
}

func TestExploitExportThatIncludesAColleaguesInvoiceIsRefusedWhole(t *testing.T) {
	expectHidden(t, "alice", "/invoices/export?ids=501,502", "INV-502")
}

func TestExploitBillingAdminsExportIntoAnotherTenantIsRefusedWhole(t *testing.T) {
	expectHidden(t, "cara", "/invoices/export?ids=502,601", "INV-601")
}

Pitfalls

  • inv.OwnerID == user.ID || user.Role == "billing_admin" lets a billing admin of any tenant read every tenant's invoices. That is this card's wrong implementation, and every test inside one tenant passes against it.
  • Filtering rows in Go after an unscoped SELECT loads other tenants' data into the process and breaks LIMIT, OFFSET and counts. Put the scope in the WHERE clause.
  • Keep the user in the context under an unexported key type such as type userKey struct{}, not a string: the context package asks for package-defined key types so no other package can set or read the value by accident.
  • ServeMux picks the most specific pattern, so GET /invoices/export and GET /invoices/{id} can be registered in either order; patterns that conflict make HandleFunc panic at startup instead.
  • r.PathValue returns the unescaped value (/invoices/a%2fb gives a/b). Parse IDs strictly, here one to nine digits, and answer 404 for anything else.
  • database/sql has no slice parameter. This card passes the ID list as one JSON argument to SQLite's json_each, which keeps the SQL text constant; never join IDs into the SQL.
  • A tenant ID from a header, a subdomain or the URL is only a selector. If you accept one, check that the signed-in user belongs to that tenant before using it.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/go-nethttp does this for you, in Docker if you do not have Go).
  2. Against your own API, sign in as a member and request a colleague's invoice ID, then as an admin of one tenant request an invoice of another tenant; both must answer 404 without the invoice number.
  3. Scanners do not help here: Semgrep's packs, gosec and CodeQL's security-extended suite report nothing about authorization on any implementation.

Scanner rules

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.

Scanner rules for Fix IDOR and missing authorization in Go and net/http
ToolRuleWhat it found here
Semgrepp/golangNo findings in either pack, on any implementation: the vulnerable, fixed and wrong queries look the same to every rule. semgrep-rules has no Go rule for object-level authorization.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config p/golang .
CodeQLgo-security-extendedOne result, not about authorization: go/log-injection on serverError's slog call, which logs r.URL.Path. slog's TextHandler quotes a value that contains spaces or non-printing characters, so a newline in the path cannot start a forged record, which the query does not model. github/codeql has no Go query for CWE-639, CWE-862 or CWE-863; the only Go query tagged CWE-285 is the experimental go/pam-auth-bypass.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
gosecdefault rule setgosec 2.29.0No findings. gosec has no rule for object-level authorization, so its silence says nothing about it.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./...

No Go scanner rule looks for a missing ownership or tenant check; the regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http, database/sql) with modernc.org/sqlite 1.60.1
modernc.org/sqlite1.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

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# 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.

Shell
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'
Shell
# 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 missing-authorization/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.

Download the project ZIP, 26 KB

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: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
Test results
fixed 15/15 pass; vulnerable 6/15 fail (6 exploit tests); wrong 3/15 fail (3 exploit tests: the other tenant's admin), with --network=none after install; the same on Go 1.26.9
Dependency freshness
Same pins as the Go SQL injection card: modernc.org/sqlite 1.60.1 (2026-09-29), gotestsum 1.13.0 (2025-09-11) and golang.org/x/text raised to 0.42.0 (2026-09-08) for GO-2026-5970. 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

  1. OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
  2. OWASP Insecure Direct Object Reference Prevention Cheat Sheet
  3. OWASP Authorization Cheat Sheet
  4. OWASP Multi-Tenant Security Cheat Sheet
  5. Go standard library: net/http ServeMux patterns and precedence
  6. Go standard library: Request.PathValue
  7. Go blog: Routing enhancements for Go 1.22
  8. Go standard library: context.WithValue (key types)
  9. Go standard library: database/sql Named
  10. SQLite: json_each

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.