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

Summary
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.
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
$ 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$ 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.@@ -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)
}
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
$ go run rand.gomath/rand seed 42: 305 987 668 750math/rand seed 42: 305 987 668 750crypto/rand token : 005fe3f23976b12b4333dd6b5682af95That 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
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
// 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
$ 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
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
}}
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, wherestringsfinds 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
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.
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 checkingerr. On an errorrespis nil and the defer panics.http.Getin a service. It useshttp.DefaultClient, which has no timeout and shares state across your whole program.- Unbounded
io.ReadAllon a response. A hostile or broken server decides how much memory you allocate. Wrap inio.LimitReader. filepath.Cleanas a containment check. It cleans; it does not contain.- Integer conversion on untrusted input.
int32(n)on a largeint64wraps silently. Go 1.22+ vet flags some of these, not all.
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
Related Cheatsheets
- Go Cheatsheet — syntax, toolchain and stdlib
- Go Logging Cheatsheet — where secrets most often leak
- Go Concurrency Cheatsheet — the race detector, and timeouts that actually cancel
- Python Security Cheatsheet — the same rules, a different runtime
Similar Articles
Related Content
More from development
Python basics and CLI cheatsheet: core syntax, f-strings, comprehensions, pathlib, venv, pip, …
Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …
You Might Also Like
Go concurrency cheatsheet: goroutines, channels, select, sync.WaitGroup, Mutex, context …
Go log/slog cheatsheet: structured logging, text and JSON handlers, levels, With and Group, context …
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.
Question 1 of 5
Quiz Complete!
Your score: 0 out of 5
Loading next question...

