# Go Security Cheatsheet

- URL: https://karandeepsingh.ca/cheatsheets/languages/go-security/
- Author: Karandeep Singh
- Published: 2025-10-07
- Tags: golang, security
- 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](https://karandeepsingh.ca/cheatsheets/languages/go-logging/).

**danger:**
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](https://pkg.go.dev/os/exec)

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

```text
$ go run deletepod.go 'web-1; echo OWNED'
# exits with status 0
| deleting pod web-1
| OWNED
# 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.
```

```text
$ 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.
```

```diff
# deletepod.go
@@ -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()
```

```go
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](https://pkg.go.dev/crypto/rand)

```text
$ go run rand.go
math/rand  seed 42: 305 987 668 750
math/rand  seed 42: 305 987 668 750
crypto/rand token : 005fe3f23976b12b4333dd6b5682af95
```

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

```go
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
```

```go
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.

**info:**
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".

## SQL

```go
// 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
```

```go
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.

## Paths

> **Docs:** [path/filepath](https://pkg.go.dev/path/filepath)

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

```text
$ 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.
```

```go
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

```go
// 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
}}
```

**danger:**
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.

```go
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:

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

## Secrets

```go
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](https://karandeepsingh.ca/cheatsheets/languages/go-logging/).
- `-ldflags "-X main.token=..."` puts it in the binary, where `strings` finds it.

```go
// 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

```bash
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)
```

**Wiring the checks into CI**
```yaml
# .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?

## Related Cheatsheets

- [Go Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/golang/) — syntax, toolchain and stdlib
- [Go Logging Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/go-logging/) — where secrets most often leak
- [Go Concurrency Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/go-concurrency/) — the race detector, and timeouts that actually cancel
- [Python Security Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-security/) — the same rules, a different runtime

## Quiz

1. Why is `exec.Command("sh", "-c", "kubectl delete pod "+name)` dangerous when `name` comes from outside?
   a) It is slower than the argv form
   b) The whole string is parsed by a shell, so `;` or `$()` in name becomes a second command
   c) sh is deprecated on modern Linux
   d) It only works if sh is bash
   Answer: b. Passing the command through sh -c means shell metacharacters in any interpolated value are interpreted. exec.Command("kubectl","delete","pod",name) passes the arguments straight to execve, where name is one opaque argument no matter what is in it.

2. What is wrong with using `math/rand` to generate a session token?
   a) Nothing, it is random enough
   b) It is a deterministic PRNG — the same seed always yields the same sequence, so tokens are predictable
   c) It is too slow
   d) It cannot produce bytes
   Answer: b. math/rand is reproducible by design, which is what makes it useful for simulations and tests. Anything that must be unguessable needs crypto/rand, which reads from the operating system CSPRNG.

3. What does `filepath.Join("/srv/configs", "../../etc/passwd")` return?
   a) An error
   b) /srv/configs/etc/passwd
   c) /etc/passwd — Join cleans the path, which resolves the ..
   d) The input unchanged
   Answer: c. Join calls Clean, which collapses .. segments, so the result escapes the base directory entirely. Join is not a containment check. Validate the user-supplied part with filepath.IsLocal before joining.

4. What happens to a Go HTTP client created with `&http.Client{}` and no Timeout?
   a) It defaults to 30 seconds
   b) It waits forever, so one unresponsive server can pin a goroutine indefinitely
   c) It inherits the context deadline automatically
   d) It retries three times then fails
   Answer: b. The zero-value Client has no timeout at all. Without one, or without a request context carrying a deadline, a hung server holds the goroutine and its connection for as long as it likes — which is a denial of service you built yourself.


