Security code review doesn't have to be intimidating. In Go codebases, certain patterns appear repeatedly. These mistakes are easy to spot once you know what to look for. These six common security issues are found in production code more often than you might expect, and learning to identify them will sharpen your security intuition for more complex issues.
Let's dive into practical examples you can start hunting for today.
Developers often use path.Clean()
(or filepath.Clean()
) thinking it sanitizes (cleans) user input for file access operations. It doesn't. This function only normalizes paths. It won't prevent directory traversal attacks.
Vulnerable Code:
func downloadFile(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
// This doesn't prevent directory traversal!
cleanPath := filepath.Clean(filename)
fullPath := filepath.Join("/var/www/files", cleanPath)
data, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "File not found", 404)
return
}
w.Write(data)
}
Impact:
An attacker can request ?file=../../../../etc/passwd
to read arbitrary files on the system. The filepath.Clean()
call normalizes this to ../../../../etc/passwd
, which still escapes the intended directory. Basically, the function path.Clean()
only "cleans" the path if it starts with a /
.
I submitted a change to the Go team, to make the documentation clearer: path: add more examples for path.Clean
Recommendations:
There are multiple ways to prevent this issue:
filepath.Clean()
after adding an os.PathSeparator
.filepath.Localize()
introduced in Go 1.23.os.Root
(Traversal-resistant file APIs) introduced in Go 1.24.The math/rand
package is deterministic and predictable: perfect for simulations, terrible for security. Developers often reach for it out of habit when generating tokens or session IDs.
Vulnerable Code:
import (
"math/rand"
"time"
)
func generateResetToken() string {
const letters = "abcdefghijklmnopqrstuvwxyz"
token := make([]byte, 32)
for i := range token {
token[i] = letters[rand.Intn(len(letters))]
}
return string(token)
}
Impact:
Tokens generated this way are predictable. An attacker can potentially predict previous and subsequent tokens.
Recommendations:
A better way to do this is to swap math/rand
for crypto/rand
. Unless you have a good reason for it, use crypto/rand
.
Checking domain ownership with strings.HasSuffix()
is a classic mistake. Developers think they're validating subdomains of their service, but they're only checking that the hostname ends with the domain.
Vulnerable Code:
func isValidSubdomain(hostname string) bool {
// This looks right but it's wrong!
return strings.HasSuffix(hostname, "example.com")
}
// isValidSubdomain("evil-example.com") returns true!
// isValidSubdomain("notexample.com") returns true!
This example is pretty simple to spot but the trusted domain could come from a configuration file or an environment variable. Making it harder to determine whether the value starts with a dot or not.
Impact:
An attacker can register evil-example.com
and bypass your validation. This leads to subdomain takeover vulnerabilities, OAuth redirect bypasses, SSRF, and CORS misconfigurations.
Recommendations:
func isValidSubdomain(hostname string) bool {
// Check exact match or proper subdomain
return hostname == "example.com" ||
strings.HasSuffix(hostname, ".example.com")
}
Using ==
to compare secrets creates timing differences that attackers can measure. Each character is compared sequentially, and comparison stops at the first mismatch.
Vulnerable Code:
func validateAPIKey(provided string) bool {
secretKey := "sk_live_abc123def456ghi789"
return provided == secretKey // Vulnerable to timing attack
}
func validateSignature(provided, expected []byte) bool {
return bytes.Equal(provided, expected) // Also vulnerable
}
Impact:
An attacker can bruteforce the secret one character at a time by measuring response times. Each correct character takes slightly longer to process than incorrect ones.
Recommendations:
You can find examples of constant-time comparisons below:
import "crypto/subtle"
func validateAPIKey(provided string) bool {
secretKey := "sk_live_abc123def456ghi789"
return subtle.ConstantTimeCompare([]byte(provided), []byte(secretKey)) == 1
}
func validateSignature(provided, expected []byte) bool {
return subtle.ConstantTimeCompare(provided, expected) == 1
}
When extracting ZIP files, trusting Entry.Name
without validation allows attackers to write files anywhere on the filesystem.
Vulnerable Code:
func extractZip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
// Dangerous: trusting f.Name directly!
path := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(path, f.Mode())
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
outFile, err := os.Create(path)
if err != nil {
return err
}
defer outFile.Close()
_, err = io.Copy(outFile, rc)
if err != nil {
return err
}
}
return nil
}
Impact:
A malicious zip file with entries like ../../../../etc/cron.d/evil
can overwrite system files, leading to remote code execution.
Recommendations:
When extracting files, you should avoid trusting the path provided in the archive. You should rely onos.Root
(Traversal-resistant file APIs) introduced in Go 1.24 to make this easier (especially since it handles symlinks).
It's shocking how often API keys, passwords, and tokens end up committed to repositories. Developers hardcode them "temporarily" during development and forget to remove them.
Vulnerable Code:
const (
// Found in too many repos!
AWSAccessKey = "AKIAIOSFODNN7EXAMPLE"
AWSSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
DBPassword = "admin123"
)
func connectToAPI() *Client {
return &Client{
APIKey: "sk_live_4242424242424242", // "We'll rotate it later"
}
}
Impact:
Exposed credentials lead to data breaches, unauthorized access, and massive cloud bills. Once committed, secrets live forever in git history even if removed later.
Recommendations:
Secrets and credentials should be kept outside of the source code: in a secret management tool, a configuration file, or in environment variables. A good rule of thumb is "an attacker should not gain any advantage from having access to your source code".
These six patterns are just the beginning. Once you train your eye to spot them, you'll start noticing variations and more subtle issues. The key is practice—review real code, understand why developers make these mistakes, and learn the secure patterns that prevent them.
Remember: every bug you find during code review is one that doesn't make it to production. That's a win for everyone involved.
Want to learn how to spot and understand issues like these? Join our next Web Security Code Review Training to practice reading real-world vulnerable code and fixing it correctly.