21 min read

White-Box Testing
Intermediate
A05 A02

Go Security

Go is the language where the standard library is on your side and the findings are all about the places someone stepped around it: a format verb instead of a placeholder, a shell where an argument vector belonged, a template type assertion that switched off contextual escaping. The review is short, and the arguments in the ticket are about intent rather than mechanism.

What Go gets right, and why that matters to a reviewer

There is no eval, no dynamic import of a string, and no exception model that hides a control-flow path. Escape hatches are visible as imports: os/exec, unsafe, text/template. That makes a Go review unusually tractable: grep the imports, then read the call sites.

SQL injection and placeholder styles

database/sql does not build queries; the driver does. That means the placeholder syntax is a property of the driver and not of the language, and a query written for PostgreSQL against a MySQL driver fails to bind rather than failing safe - the parameter stays as text. The reviewer's first question is therefore which driver is in go.mod, and the second is whether any call site uses it correctly.

Identifiers cannot be bound, which is where concatenation re-enters real code: a sort column, a table name, an ORDER BY direction. Each one needs a mapping from an allowed value to a literal, not a validation regex.

users.go
// fmt.Sprintf into Query: the classic Go injection
func findUser(db *sql.DB, name string) (*User, error) {
    q := fmt.Sprintf("SELECT id, email FROM users WHERE name = '%s'", name)
    return scanUser(db.QueryRow(q))
}

// the driver does the formatting, but the query is still concatenated
q := "SELECT * FROM orders WHERE status = '" + status + "'"
db.Query(q)

// identifiers cannot be bound, so one concatenation asks for an allowlist
db.Query("SELECT * FROM " + tableName + " WHERE id = ?", id)
// fmt.Sprintf into Query: the classic Go injection
func findUser(db *sql.DB, name string) (*User, error) {
    q := fmt.Sprintf("SELECT id, email FROM users WHERE name = '%s'", name)
    return scanUser(db.QueryRow(q))
}

// the driver does the formatting, but the query is still concatenated
q := "SELECT * FROM orders WHERE status = '" + status + "'"
db.Query(q)

// identifiers cannot be bound, so one concatenation asks for an allowlist
db.Query("SELECT * FROM " + tableName + " WHERE id = ?", id)

Prepared statements are not the same thing as binding

An sql.Stmt prepared once and executed with arguments is safe. A statement prepared from a string that already contains the value is not, and it is a common "fix" in code that had the injection reported once before. Read the Prepare argument, not the Exec call.

Command execution

exec.Command takes a program and an argument slice and runs it without a shell. That design removes the whole class of injection that PHP and Node have, and it is also why the finding in a Go repository is almost always a deliberate reintroduction: sh -c was added to get a pipe or a redirect working.

convert.go
// exec.Command does not use a shell, so this is safe...
exec.Command("convert", userFile, "out.png").Run()

// ...until a shell is reintroduced to get redirection or a pipeline
exec.Command("sh", "-c", "convert "+userFile+" out.png").Run()

// or the binary itself is user controlled
exec.Command(req.FormValue("tool"), "--version").Run()

// and arguments joined into one string lose the vector boundary
args := strings.Join(userArgs, " ")
exec.Command("tar", args).Run()
// exec.Command does not use a shell, so this is safe...
exec.Command("convert", userFile, "out.png").Run()

// ...until a shell is reintroduced to get redirection or a pipeline
exec.Command("sh", "-c", "convert "+userFile+" out.png").Run()

// or the binary itself is user controlled
exec.Command(req.FormValue("tool"), "--version").Run()

// and arguments joined into one string lose the vector boundary
args := strings.Join(userArgs, " ")
exec.Command("tar", args).Run()
Argument injection is real even without a shell

A file named --output=/etc/cron.d/x is not shell injection, but it is an argument to a program that parses options. Any user value in an argv position the program treats as a flag is a finding: put a -- separator before user-controlled paths where the tool supports it, and reject values starting with a dash where it does not.

Template injection

The distinction that matters is one import: html/template understands HTML context and escapes accordingly; text/template performs no escaping at all. Both are correct for their job, and the finding is the file where the wrong one was chosen - usually because the output was not HTML until someone added a web handler in front of it.

render.go
// text/template performs no escaping at all
t := template.Must(template.New("page").Parse(pageSource))
t.Execute(w, data) // data can contain <script>

// html/template escapes by default, but the type can switch it off
type SafeHTML string // implementing template.HTML on user input
t.Execute(w, template.HTML(userComment))

// a template parsed from user input is itself a template injection
tpl := template.Must(template.New("x").Parse(req.FormValue("body")))
tpl.Execute(w, nil)
// text/template performs no escaping at all
t := template.Must(template.New("page").Parse(pageSource))
t.Execute(w, data) // data can contain <script>

// html/template escapes by default, but the type can switch it off
type SafeHTML string // implementing template.HTML on user input
t.Execute(w, template.HTML(userComment))

// a template parsed from user input is itself a template injection
tpl := template.Must(template.New("x").Parse(req.FormValue("body")))
tpl.Execute(w, nil)

template.HTML is a claim, not a cast

Converting a string to template.HTML tells the escaping engine to trust it. Every such conversion is a finding until you can name the sanitiser that produced the value. This is also the one place where review can catch what a scanner cannot: the taint is broken by an assertion on a line that looks like a type conversion.

Randomness

A large share of Go findings written from memory say that math/rand is predictable because nobody called rand.Seed. That stopped being true in Go 1.20, which seeds the global source automatically. The defect did not go away; it changed shape - the output is now unpredictable to a casual observer and still entirely unsuitable as a secret, because the generator is not cryptographically secure and the state is recoverable.

reset.go
// "math/rand is predictable" is only half the finding
n := rand.Intn(1000000) // used as a password reset code

// the seed question is obsolete: since Go 1.20 the global source is
// seeded automatically, so this no longer produces the same sequence
// on every run - it produces a predictable one instead
r := rand.New(rand.NewSource(time.Now().UnixNano()))
token := fmt.Sprintf("%d", r.Int63()) // a token guessable from the clock

// math/rand/v2 is better, and still not a CSPRNG
u := rand.Uint64()
// "math/rand is predictable" is only half the finding
n := rand.Intn(1000000) // used as a password reset code

// the seed question is obsolete: since Go 1.20 the global source is
// seeded automatically, so this no longer produces the same sequence
// on every run - it produces a predictable one instead
r := rand.New(rand.NewSource(time.Now().UnixNano()))
token := fmt.Sprintf("%d", r.Int63()) // a token guessable from the clock

// math/rand/v2 is better, and still not a CSPRNG
u := rand.Uint64()
Use Correct source Why
Session token, reset code, API key crypto/rand Must be unguessable even with the output history
Idempotency key, request ID crypto/rand An attacker who can predict it can collide it
Jitter, sampling, shuffle math/rand/v2 Performance matters and predictability does not
Test fixtures math/rand with a fixed seed Reproducibility is the requirement

SSRF and outbound requests

A URL that is parsed and checked before the request is not a control, because the check and the connection resolve the host differently. Decimal and hex IPv4 forms, IPv6 literals such as [::1], 0.0.0.0, a hostname that points at an internal service, and a redirect from a public host all pass a string check. The Go-specific fix is clean: the address is available at dial time, so put the decision there.

fetch.go
// a string check on the URL, before the request is made
u, _ := url.Parse(target)
if strings.HasPrefix(u.Host, "127.0.0.1") {
    return errors.New("blocked")
}
http.Get(target)

// the check misses: decimal and hex IPv4, [::1], 0.0.0.0, and any
// hostname that resolves to an internal address

// a scheme allowlist without an address check
if u.Scheme != "http" && u.Scheme != "https" {
    return errors.New("blocked")
}
http.Get(target) // still reaches the metadata service
// a string check on the URL, before the request is made
u, _ := url.Parse(target)
if strings.HasPrefix(u.Host, "127.0.0.1") {
    return errors.New("blocked")
}
http.Get(target)

// the check misses: decimal and hex IPv4, [::1], 0.0.0.0, and any
// hostname that resolves to an internal address

// a scheme allowlist without an address check
if u.Scheme != "http" && u.Scheme != "https" {
    return errors.New("blocked")
}
http.Get(target) // still reaches the metadata service

One dialer, injected everywhere

The control is only a control if there is one of it. Build the transport at startup, pass it to every client that fetches a user-supplied target, and make the raw http.Get path unavailable by not importing it in those packages.

TLS configuration

A tls.Config is safe when it is empty. Every finding in this section is a field someone added, and the comment above it says why. Read the comment as evidence: "internal service" usually means a private CA was never distributed, and that is the thing to fix.

client.go
// verification disabled "for the internal service"
tr := &http.Transport{
    TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}

// a minimum version that is still negotiable downwards
srv := &http.Server{
    TLSConfig: &tls.Config{MinVersion: tls.VersionTLS10},
}

// a server certificate loaded without its chain, and CipherSuites
// pinned to a list that includes a CBC suite for a legacy client
// verification disabled "for the internal service"
tr := &http.Transport{
    TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}

// a minimum version that is still negotiable downwards
srv := &http.Server{
    TLSConfig: &tls.Config{MinVersion: tls.VersionTLS10},
}

// a server certificate loaded without its chain, and CipherSuites
// pinned to a list that includes a CBC suite for a legacy client
Check Expected Where to look
Every query is prepared or bound, with the placeholder style the driver expects No fmt.Sprintf or string concatenation reaching Query, QueryRow or Exec, and ? versus $1 matching the actual driver The data access layer, and every hand-written query in a migration or report
Process execution passes a fixed binary and an argument slice exec.Command or CommandContext with a literal first argument; sh -c only where a shell is genuinely required, with the script fixed The wrapper package, not the handler
Markup is produced only through html/template text/template appears only for non-markup output such as email bodies, config files or logs Every template.Parse call and the render path that feeds it
Outbound requests to user-supplied targets are filtered at dial time A net.Dialer Control function or a transport with an allowlist, rejecting loopback, link-local, private ranges and resolved-then-rebound addresses The HTTP client construction, once

Evidence Collection

Go findings are usually one line long and easy to dispute, so quote the whole call and the type that reaches it:

The call with its arguments: Query, Exec, Command, ParseFiles, Get - including the format verb or the concatenation. The argument list is the finding.

Where the string is typed: Check whether the value is a string from a request, or a template.HTML, SafeHTML or similar alias. The alias is what removed the escaping.

Whether the path is reachable: Go repositories accumulate internal packages. A sink in an unused helper is a cleanup item, not a vulnerability, and the difference is the route that reaches it.

Bedrock Questions Before Filing

Go is a small language with a large standard library, and the standard library is usually doing the right thing:

  • Is there actually a shell: exec.Command with a literal binary and separate arguments does not interpret metacharacters. It is not command injection, even when an argument contains a semicolon.
  • Is the template html/template: Escaping is contextual and automatic. A value rendered through an action is not an XSS finding unless it was typed as trusted markup.
  • Is math/rand protecting anything: Since Go 1.20 the global source is seeded automatically, so the old "same sequence every run" report is stale. The finding is only real when the output is a secret or an unguessable identifier.
  • Does the SSRF check run at dial time: A string check on the host is a finding even if it looks thorough. An address check in a Dialer Control function is not.

Reviewer Checklist

Ordered by how often each one turns into a real finding in a Go codebase.

  • ☐ Grep for Sprintf near Query, QueryRow, Exec and Raw
  • ☐ Confirm the placeholder style matches the driver: ? for MySQL and SQLite, $1 for PostgreSQL
  • ☐ Check every concatenated identifier against an allowlist
  • ☐ Check that context variants are used, so a query cannot outlive its request
  • ☐ Grep for exec.Command and look for sh -c, bash -c, or a request value in argv[0]
  • ☐ Check file paths for a prefix test on the cleaned absolute path
  • ☐ Check archive extraction for zip-slip: the target path must stay inside the destination
  • ☐ Grep for text/template and confirm the output is not HTML
  • ☐ Grep for template.HTML and template.JS conversions and trace each to its source
  • ☐ Check that no template is parsed from a request value
  • ☐ Check every http.Client for a dial-time address control or a literal allowlist
  • ☐ Grep for InsecureSkipVerify and read the reason, not the comment
  • ☐ Confirm secrets and tokens come from crypto/rand, and comparisons use subtle

Remediation Notes

The Go fixes are small, which is why the interesting question is why they were skipped.

Why Go findings are usually one line and hard to write up

The mechanism is a single call - one format verb, one type assertion, one boolean field - and the impact depends entirely on context that is not in the diff. That makes the evidence harder than the detection: quote the call, quote the type, and name the route that reaches it. A Go finding without the route is a style comment, and it will be closed as one.

Go Review Practice Targets

Repositories where the defects are deliberate and the fix is one call away, so the review can be checked against the answer.

OWASP Goat Custom Lab easy
Deliberately vulnerable Go web applicationDocker-based setupWalk each documented challenge back to its sink
gosec Custom Lab medium
Read the rule set as a checklistRun it against a repository you know and diff the output with your own reviewUnderstand the rules it cannot express
OWASP Benchmark (Go ports) Custom Lab medium
Compare scanner precision across sink familiesStudy the false-positive shapesUse the annotated categories to calibrate triage