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
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.
// 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)// placeholders, matched to the driver
func findUser(db *sql.DB, name string) (*User, error) {
return scanUser(db.QueryRow(
"SELECT id, email FROM users WHERE name = ?", name,
))
}
// PostgreSQL (lib/pq, pgx stdlib): numbered placeholders
db.QueryRow("SELECT id FROM users WHERE email = $1", email)
// identifiers: map to a known set rather than concatenating
allowed := map[string]bool{"orders": true, "refunds": true}
if !allowed[tableName] {
return nil, ErrInvalidTable
}
db.Query("SELECT * FROM " + tableName + " WHERE id = ?", id)// placeholders, matched to the driver
func findUser(db *sql.DB, name string) (*User, error) {
return scanUser(db.QueryRow(
"SELECT id, email FROM users WHERE name = ?", name,
))
}
// PostgreSQL (lib/pq, pgx stdlib): numbered placeholders
db.QueryRow("SELECT id FROM users WHERE email = $1", email)
// identifiers: map to a known set rather than concatenating
allowed := map[string]bool{"orders": true, "refunds": true}
if !allowed[tableName] {
return nil, ErrInvalidTable
}
db.Query("SELECT * FROM " + tableName + " WHERE id = ?", id)Prepared statements are not the same thing as binding
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.
// 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()// fixed binary, argument vector, no shell
cmd := exec.CommandContext(ctx, "convert", userFile, "out.png")
// a pipeline without a shell: two commands, one pipe
first := exec.CommandContext(ctx, "gzip", "-dc", userFile)
second := exec.CommandContext(ctx, "tar", "-xf", "-")
pipe, _ := first.StdoutPipe()
second.Stdin = pipe
first.Start()
second.Run()
// user-selected tool: allowlist before exec, never the request value itself
tool, ok := approvedTools[req.FormValue("tool")]
if !ok {
return fmt.Errorf("unknown tool")
}
exec.CommandContext(ctx, tool, "--version").Run()// fixed binary, argument vector, no shell
cmd := exec.CommandContext(ctx, "convert", userFile, "out.png")
// a pipeline without a shell: two commands, one pipe
first := exec.CommandContext(ctx, "gzip", "-dc", userFile)
second := exec.CommandContext(ctx, "tar", "-xf", "-")
pipe, _ := first.StdoutPipe()
second.Stdin = pipe
first.Start()
second.Run()
// user-selected tool: allowlist before exec, never the request value itself
tool, ok := approvedTools[req.FormValue("tool")]
if !ok {
return fmt.Errorf("unknown tool")
}
exec.CommandContext(ctx, tool, "--version").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.
// 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)// markup must come from html/template
t := template.Must(template.New("page").ParseFiles("templates/page.html"))
t.Execute(w, data) // contextual escaping, per action
// user content stays a string; only trusted, pre-rendered markup is typed
type TrustedHTML = template.HTML // assign only from a sanitiser's output
// user-supplied templates are not templates: treat them as data
t.Execute(w, map[string]string{"body": req.FormValue("body")})
// outside HTML (email, config, YAML) text/template is correct -
// escaping HTML into a mail body is its own bug// markup must come from html/template
t := template.Must(template.New("page").ParseFiles("templates/page.html"))
t.Execute(w, data) // contextual escaping, per action
// user content stays a string; only trusted, pre-rendered markup is typed
type TrustedHTML = template.HTML // assign only from a sanitiser's output
// user-supplied templates are not templates: treat them as data
t.Execute(w, map[string]string{"body": req.FormValue("body")})
// outside HTML (email, config, YAML) text/template is correct -
// escaping HTML into a mail body is its own bugtemplate.HTML is a claim, not a cast
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.
// "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()// anything an attacker must not predict comes from crypto/rand
func resetCode() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { // crypto/rand
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// comparison of secrets uses constant time
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
return ErrInvalidCode
}
// math/rand remains correct for jitter, sampling and test data// anything an attacker must not predict comes from crypto/rand
func resetCode() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { // crypto/rand
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// comparison of secrets uses constant time
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
return ErrInvalidCode
}
// math/rand remains correct for jitter, sampling and test data| 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.
// 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// the check happens at dial time, on the resolved address
dialer := &net.Dialer{
Control: func(network, address string, _ syscall.RawConn) error {
host, _, _ := net.SplitHostPort(address)
ip := net.ParseIP(host)
if ip == nil || ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
return fmt.Errorf("blocked address %s", host)
}
return nil
},
}
client := &http.Client{
Transport: &http.Transport{DialContext: dialer.DialContext},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return nil
},
}
// allowlist the host if the feature has a known set of targets,
// and keep redirects from leaving it// the check happens at dial time, on the resolved address
dialer := &net.Dialer{
Control: func(network, address string, _ syscall.RawConn) error {
host, _, _ := net.SplitHostPort(address)
ip := net.ParseIP(host)
if ip == nil || ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
return fmt.Errorf("blocked address %s", host)
}
return nil
},
}
client := &http.Client{
Transport: &http.Transport{DialContext: dialer.DialContext},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return nil
},
}
// allowlist the host if the feature has a known set of targets,
// and keep redirects from leaving itOne dialer, injected everywhere
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.
// 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// verification on, and the system roots are the trust decision
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
// if an internal CA is the reason someone disabled verification,
// pin that CA instead - this is the actual fix
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(internalCA)
tr.TLSClientConfig.RootCAs = pool
// server side: TLS 1.2 minimum, no version or cipher pinning
srv := &http.Server{
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}// verification on, and the system roots are the trust decision
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
// if an internal CA is the reason someone disabled verification,
// pin that CA instead - this is the actual fix
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(internalCA)
tr.TLSClientConfig.RootCAs = pool
// server side: TLS 1.2 minimum, no version or cipher pinning
srv := &http.Server{
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}| 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.
Related Source Code Review Guides
SAST Fundamentals
Taint, sinks and the triage model this page applies to Go.
Server-Side Request Forgery
The fix catalogue for the outbound-request findings on this page.
Injection
Parameter binding across languages, and why escaping is not the same control.
Triage and Remediation
What to do with these findings once they are written down.