/user/kayd @ devops :~$ cat go-security.md

Go Security Cheatsheet Go Security Cheatsheet

QR Code linking to: Go Security Cheatsheet
Karandeep Singh
Karandeep Singh
• 7 minutes

Summary

The security rules I check before Go code touches a server, covering command execution, randomness, SQL parameters, path traversal, TLS verification, HTTP timeouts and dependency scanning.

Go removes whole categories of bug — no buffer overflows, no use-after-free — and leaves the ones that come from what the program is told to do. A string handed to a shell, a token from the wrong random source, a path joined without checking. This sheet is what I check before Go code goes near a server, with the safe form next to the one that bites, and every demonstration below was actually run. For how secrets leak after the fact, see the Go Logging Cheatsheet.

caution
The three that cause real incidents, in order: sh -c with an interpolated value, math/rand where crypto/rand was needed, and a missing timeout. Everything else here is a distant fourth.

Running Commands

Docs: os/exec

exec.Command does not use a shell. That is the whole security property, and sh -c throws it away:

Expand your knowledge with Docker Cheatsheet

it succeeds, and that is the problem exit 0
$ go run deletepod.go 'web-1; echo OWNED'deleting pod web-1OWNED# exec.Command("sh","-c","echo deleting pod "+name). The shell split at the# semicolon and ran the second command. Exit status 0, so nothing notices.$ echo $? → 0
the same input, argv form
$ SAFE=1 go run deletepod.go 'web-1; echo OWNED'deleting pod web-1; echo OWNED# exec.Command("echo","deleting pod",name). One argument, handed to execve# untouched. There is no shell to do any splitting.
git diff — deletepod.gothe whole fix
@@ -8,5 +8,5 @@ func deletePod(name string) error {-    cmd := exec.Command("sh", "-c", "kubectl delete pod "+name)+    cmd := exec.CommandContext(ctx, "kubectl", "delete", "pod", name)     return cmd.Run()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "kubectl", "get", "pods", "-o", "json")
out, err := cmd.Output()                 // stdout only; stderr in ExitError
var ee *exec.ExitError
if errors.As(err, &ee) {
    log.Printf("exit %d: %s", ee.ExitCode(), ee.Stderr)
}
warning
exec.LookPath resolves against $PATH, so a writable directory early in PATH lets someone else choose which binary you run. In a service, use an absolute path — /usr/bin/kubectl — or set cmd.Env explicitly rather than inheriting the caller’s.

Randomness

Docs: crypto/rand

the same seed, twice
$ go run rand.gomath/rand  seed 42: 305 987 668 750math/rand  seed 42: 305 987 668 750crypto/rand token : 005fe3f23976b12b4333dd6b5682af95

That reproducibility is the point of math/rand and the disqualification for anything secret:

import crand "crypto/rand"

b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {   // never ignore this error
    return err
}
token := base64.RawURLEncoding.EncodeToString(b)

n, err := crand.Int(crand.Reader, big.NewInt(100))   // unbiased 0..99
subtle.ConstantTimeCompare(got, want) == 1   // not bytes.Equal, for secrets

bytes.Equal returns as soon as it finds a difference, so how long it takes leaks how much of the prefix was right.

Deepen your understanding in Python Security Cheatsheet

note
Go 1.20 removed the need to seed math/rand, and Go 1.22’s math/rand/v2 is seeded randomly by default. Neither change makes it suitable for tokens — “not reproducible across runs” is a much weaker property than “unguessable”.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

SQL

// NO: the value becomes part of the statement
db.Query("SELECT * FROM pods WHERE name = '" + name + "'")
db.Query(fmt.Sprintf("SELECT * FROM pods WHERE name = '%s'", name))

// YES: a bound parameter, sent separately from the SQL
db.QueryContext(ctx, "SELECT * FROM pods WHERE name = $1", name)   // postgres
db.QueryContext(ctx, "SELECT * FROM pods WHERE name = ?", name)    // mysql
rows, err := db.QueryContext(ctx, q, name)
if err != nil { return err }
defer rows.Close()                 // leaks a connection without this
for rows.Next() { ... }
return rows.Err()                  // the error you forget to check

A table or column name cannot be a parameter — only values can. If one must be dynamic, check it against an allow-list you wrote.

Explore this further in Git Cheatsheet

Paths

Docs: path/filepath

filepath.Join calls Clean, which resolves ... It is not a containment check, and treating it as one is the bug:

Discover related concepts in Bash sed & awk Cheatsheet

Join does not keep you inside the base
$ go run paths.go"api.yaml"         -> /srv/configs/api.yaml        IsLocal(api.yaml)=true"../../etc/passwd" -> /etc/passwd                  IsLocal(../../etc/passwd)=false"sub/ok.yaml"      -> /srv/configs/sub/ok.yaml     IsLocal(sub/ok.yaml)=true"/etc/shadow"      -> /srv/configs/etc/shadow      IsLocal(/etc/shadow)=false# Join("/srv/configs", "../../etc/passwd") is /etc/passwd. Completely out.
if !filepath.IsLocal(userPath) {        // 1.20+: rejects .. and absolute paths
    return errors.New("path escapes the base directory")
}
f, err := os.Open(filepath.Join(base, userPath))

root, err := os.OpenRoot(base)          // 1.24+: the whole problem, solved
f, err := root.Open(userPath)           // cannot escape, even via a symlink
tip
os.OpenRoot (Go 1.24) is the one to reach for in new code. It holds a directory handle and resolves every path inside it, so .. and a symlink pointing outside are both refused by the kernel rather than by your check.

TLS and HTTP

// NO: accepts any certificate, from anyone on the path
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}

// YES: trust a private CA explicitly
pool := x509.NewCertPool()
pem, _ := os.ReadFile("/etc/ssl/corp-ca.pem")
pool.AppendCertsFromPEM(pem)
tr := &http.Transport{TLSClientConfig: &tls.Config{
    RootCAs:    pool,
    MinVersion: tls.VersionTLS12,        // the zero value allows older
}}
caution
A http.Client{} with no Timeout waits forever. One unresponsive server then holds a goroutine and a connection until the process restarts — a denial of service you inflicted on yourself. Set it on the client, and use http.NewRequestWithContext for per-call deadlines.
c := &http.Client{Timeout: 10 * time.Second}

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := c.Do(req)
if err != nil { return err }
defer resp.Body.Close()                       // always, or you leak the conn
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))  // cap what you read

On the server side the zero-value http.Server has no timeouts either:

Uncover more details in Bash Basics Cheatsheet

srv := &http.Server{
    Addr:              ":8080",
    ReadHeaderTimeout: 5 * time.Second,     // slowloris
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
}

Secrets

token, ok := os.LookupEnv("API_TOKEN")
if !ok { return errors.New("API_TOKEN not set") }   // fail loudly, at startup
  • No secret in source, in a const, or in a committed .env.
  • No secret in a log line or an error string — see the logging sheet.
  • -ldflags "-X main.token=..." puts it in the binary, where strings finds it.
// Keep a secret out of a %v on a struct
type Config struct {
    Host  string
    Token Secret
}
type Secret string
func (Secret) String() string { return "[REDACTED]" }   // fmt uses this
warning
That String() method only fires for %v, %s and fmt.Print. %#v, encoding/json and a debugger all still see the real value — so it is a guard against accidental logging, not a secret store.

Dependencies and Scanning

go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...        # CVEs, filtered to functions you actually call

go list -m all           # the full module graph
go mod verify            # checksums match go.sum
go mod tidy -diff        # fails if tidy would change anything (recent Go)
# .github/workflows/go.yml
- run: go vet ./...
- run: go test -race ./...        # the race detector, where it belongs
- run: go mod verify
- uses: golang/govulncheck-action@v1

govulncheck is quieter than a generic scanner because it does reachability analysis — a CVE in a function your code never calls is reported separately from one it does, so the list is short enough to act on.

Pin the toolchain in go.mod (toolchain go1.24.7) so a build cannot silently move to a version you have not tested.

tip
go vet ./... is free and already installed. It catches the slog dangling key, unreachable code, bad Printf verbs and lost context.CancelFunc calls. It belongs in CI before any third-party linter.

Common Pitfalls

  • Ignoring the error from crand.Read. It is nearly never non-nil, and if it is, you have just built a token out of zero bytes.
  • defer resp.Body.Close() before checking err. On an error resp is nil and the defer panics.
  • http.Get in a service. It uses http.DefaultClient, which has no timeout and shares state across your whole program.
  • Unbounded io.ReadAll on a response. A hostile or broken server decides how much memory you allocate. Wrap in io.LimitReader.
  • filepath.Clean as a containment check. It cleans; it does not contain.
  • Integer conversion on untrusted input. int32(n) on a large int64 wraps silently. Go 1.22+ vet flags some of these, not all.
Question

Find the last `exec.Command` in your codebase. Is `sh` or `bash` the first argument — and if so, who controls the string being built after it?

Gain comprehensive insights from Ansible Cheatsheet

Similar Articles

More from development

Go (Golang) Cheatsheet

Go cheatsheet covering the go toolchain, syntax essentials, goroutines and channels, table-driven …

Bash Basics Cheatsheet

Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

🍪 We use cookies for analytics. Read the privacy policy