# Security review checklist for AI-generated Go

> Source: https://learn-go.org/review/security/
> Part of Learn Go, free to read.

Go is a good language for writing secure code and generated Go is usually reasonable — parameterised queries by default, no buffer overflows, sane crypto defaults. What survives review is narrow and specific: **choosing the wrong package from a pair that look interchangeable**, and the handful of things `gosec` cannot infer.

## Turn on the machine checks first

```bash
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...                 # CVEs, but only for code paths you actually call
golangci-lint run                 # with gosec enabled
```

`govulncheck` is the standout tool here and has no equivalent in most ecosystems: it does reachability analysis, so it reports the vulnerable functions your code can actually reach rather than every CVE in your dependency tree. That means near-zero noise, which means people act on it.

```yaml .golangci.yml
linters:
  enable: [gosec, bodyclose, noctx, contextcheck, errcheck, errorlint, sqlclosecheck]
```

## The wrong-package mistakes

### 1. `text/template` where `html/template` was needed

The most consequential single mistake in generated Go web code.

```go
import "text/template"                    // no escaping whatsoever

t := template.Must(template.New("p").Parse(`<div>{{.Comment}}</div>`))
t.Execute(w, data)                        // stored XSS
```

The two packages have **identical APIs**, so swapping the import compiles and passes every test. `html/template` is contextually aware — it escapes differently inside an attribute, a URL, a script block — and `text/template` does nothing at all.

**Correct:** `import "html/template"` for anything that reaches a browser.

**Catch it with:** `grep -rn '"text/template"' --include='*.go' .` in review, and a line in [your `AGENTS.md`](/ai/agents-md/). No linter reliably distinguishes intent here.

### 2. `math/rand` where `crypto/rand` was needed

```go
token := fmt.Sprintf("%x", rand.Int63())   // predictable
```

Session tokens, password reset links, nonces, API keys, filenames in a shared directory. `math/rand/v2` is better distributed than the old package and is still not cryptographically secure.

```go
b := make([]byte, 32)
if _, err := crypto_rand.Read(b); err != nil {
    return fmt.Errorf("generate token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(b)
```

**Catch it with:** `gosec` G404.

### 3. `==` on secrets

```go
if mac == expectedMAC {                   // early return leaks timing
```

`subtle.ConstantTimeCompare` for MACs, tokens and password hashes. **Catch it with:** `gosec` G201-family rules, and review.

## Injection

### 4. SQL built with fmt.Sprintf

```go
q := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
db.Query(q)
```

Still generated when the surrounding code does not use an ORM or `sqlc`. Use placeholders — `db.Query("... WHERE email = $1", email)`. **Catch it with:** `gosec` G201/G202.

Note that a query built for a dynamic `ORDER BY` cannot be parameterised. That case needs an allowlist:

```go
var allowed = map[string]string{"name": "name", "created": "created_at"}
col, ok := allowed[req.SortBy]
if !ok { return ErrBadSort }
```

### 5. Command execution

```go
exec.Command("sh", "-c", "git log --author="+author)   // injection
exec.Command("git", "log", "--author="+author)         // fine: no shell
```

Go's `exec.Command` takes an argument slice and does **not** invoke a shell, which makes it safe by default — until generated code reaches for `sh -c` to get pipes or globbing. **Catch it with:** `grep -rn '"sh", "-c"' --include='*.go'` and `gosec` G204.

Also check `exec.LookPath` behaviour: a relative or attacker-influenced `PATH` means you may run a different binary than you intended.

## Web-layer

### 6. SSRF

```go
func fetchAvatar(w http.ResponseWriter, r *http.Request) {
    url := r.URL.Query().Get("url")
    resp, _ := http.Get(url)               // now fetches your metadata endpoint
```

Generated "import from URL", webhook and avatar features almost never validate. Allowlist the scheme and host, resolve the name and reject private ranges, and disable redirects:

```go
client := &http.Client{
    CheckRedirect: func(*http.Request, []*http.Request) error {
        return http.ErrUseLastResponse         // do not follow into the private range
    },
    Timeout: 10 * time.Second,
}
```

A redirect check matters: an attacker controls a public host that 302s to `169.254.169.254`.

### 7. Path traversal

```go
http.ServeFile(w, r, filepath.Join(dir, r.URL.Path))   // ../../etc/passwd
```

Use `http.FileServer` with `http.Dir`, which handles this, or `os.Root` (Go 1.24+) for a directory you cannot escape. If you must join manually, `filepath.Clean` then verify the result is still inside the base with a separator-aware prefix check.

### 8. Missing timeouts

```go
http.ListenAndServe(":8080", mux)          // no timeouts at all
```

The zero-value `http.Server` has no read, write or idle timeout, so a slow client can hold a connection indefinitely. Generated servers use the one-liner constantly.

```go
srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,     // the Slowloris one
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}
```

**Catch it with:** `gosec` G112 for `ReadHeaderTimeout` specifically.

### 9. Unbounded reads

```go
body, _ := io.ReadAll(r.Body)              // attacker decides your memory usage
```

`http.MaxBytesReader(w, r.Body, 1<<20)` first. Same for `json.Decoder` — and use `DisallowUnknownFields` to catch mass assignment through an API you did not intend to expose.

### 10. Decompression bombs

`gzip.NewReader` followed by `io.ReadAll` will happily expand a small upload into all your memory. Wrap with `io.LimitReader` on the *decompressed* side.

## Types and numbers

### 11. Integer conversion truncating

```go
var n int64 = userSuppliedValue
buf := make([]byte, int32(n))              // silently truncates or goes negative
```

Go 1.24's vet includes checks for some of these, and `gosec` G115 flags integer overflow conversions. Worth enabling — it is a real source of allocation bugs at boundaries.

### 12. Errors ignored on security-relevant calls

```go
_ = json.Unmarshal(data, &claims)          // parse failed; claims is zero-valued
if claims.Role == "admin" { }              // zero value is "", so this is fine — this time
```

The pattern is dangerous whenever the zero value happens to be permissive. `errcheck` catches the discard; review catches whether the zero value is safe.

## Authorisation

The most common real vulnerability in any web codebase, and no linter finds it:

```go
func (h *Handler) GetInvoice(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    inv, err := h.store.Invoice(r.Context(), id)   // any authenticated user, any invoice
```

The ownership check belongs beside the query:

```go
inv, err := h.store.InvoiceForUser(r.Context(), id, user.ID)
if errors.Is(err, sql.ErrNoRows) {
    http.NotFound(w, r)                    // 404, not 403 — do not confirm it exists
    return
}
```

Write the test once per resource type:

```go
func TestCannotReadAnotherUsersInvoice(t *testing.T) {
    rr := doRequest(t, "GET", "/invoices/"+bobInvoiceID, aliceToken)
    if rr.Code != http.StatusNotFound {
        t.Errorf("status = %d, want 404", rr.Code)
    }
}
```

## TLS and crypto

Go's defaults are good. What generated code gets wrong is disabling them:

```go
tls.Config{InsecureSkipVerify: true}       // never in production
tls.Config{MinVersion: tls.VersionTLS10}   // set 1.2 or 1.3
```

`InsecureSkipVerify` appears in generated code as a way past a certificate error during development and then survives into main. **Catch it with:** `gosec` G402, and a grep in review.

## The review, as commands

```bash
govulncheck ./...
golangci-lint run
grep -rn '"text/template"' --include='*.go' .          # XSS
grep -rn 'InsecureSkipVerify' --include='*.go' .
grep -rn '"sh", "-c"' --include='*.go' .
grep -rn 'math/rand' --include='*.go' . | grep -iE 'token|secret|key|nonce|session'
grep -rn 'ListenAndServe' --include='*.go' .           # missing timeouts
```

Under a minute, and it covers the automatable half. The half worth your attention is authorisation and SSRF, because both depend on knowing what your system is supposed to allow.

:::verdict The Go-specific thing to remember
Two pairs of standard library packages look identical and are not: `text/template` vs `html/template`, and `math/rand` vs `crypto/rand`. Picking the wrong one compiles, passes tests, and is a vulnerability. Grep for both on every review.
:::

## Common questions

### Is `govulncheck` better than a generic scanner?

For Go, substantially — it uses call-graph reachability, so it reports vulnerabilities your code can actually reach rather than every CVE in the module graph. That means far less noise, which is the difference between a tool people act on and one they mute.

### Does the standard library's `net/http` need a framework to be secure?

No. It needs timeouts set explicitly, body size limits, and `html/template` for rendering. A framework may give you those by default, which is a real convenience, but nothing about `net/http` is insecure once configured.

### Why does generated Go use `InsecureSkipVerify`?

Because it is the answer to "how do I get past this certificate error", and a great deal of example code contains it. Treat any occurrence as a defect and require a comment explaining the specific test scenario if it must exist at all.

### Should I use `os.Root` for file serving?

If you are on Go 1.24 or later and serving files from a directory, yes — it gives you a handle that cannot be escaped by traversal or symlinks, which is a stronger guarantee than any amount of path cleaning.
