Skip to main content

Go Security Best Practices

Go is a modern programming language that has gained popularity for its simplicity, efficiency, and strong support for concurrent programming. However, like any other language, writing secure Go code requires an understanding of common vulnerabilities and best practices to mitigate them. This guide provides a comprehensive overview of Go security, drawing from official documentation and community-driven projects.

Table of Contents

Vulnerability Management

A key aspect of maintaining a secure application is managing dependencies and scanning for known vulnerabilities.

Keeping Go and Dependencies Updated

The Go team regularly releases new versions of the language that include security patches, bug fixes, and performance improvements. It is crucial to keep your Go version up-to-date. You can check for the latest release on the official Go website.

Similarly, third-party dependencies can introduce vulnerabilities. Use go list -m -json all to review your project's dependencies and ensure they are current. To understand why a specific package is a dependency, you can use go mod why -m <module-path>.

Vulnerability Scanning with govulncheck

The Go team provides govulncheck, a tool to scan your project for known vulnerabilities. It's backed by the Go vulnerability database. What makes govulncheck particularly effective is that it only reports vulnerabilities in functions that your code is actually calling, reducing noise from irrelevant alerts.

To scan your project, run:

go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

govulncheck can be integrated into your CI/CD pipeline to automate vulnerability detection. The Go team provides a GitHub Action for govulncheck.

Secure Coding Practices

The OWASP Go Secure Coding Practices guide is an excellent resource for developers. It covers a wide range of security topics.

Input Validation

Always validate and sanitize input from external sources, such as users, APIs, or files. This is the first line of defense against many types of attacks, including injection attacks.

Example: Validating a numeric ID

package main

import (
"fmt"
"net/http"
"strconv"
)

func getUserHandler(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// ... proceed with fetching user
fmt.Fprintf(w, "Fetching user with ID: %d", id)
}

func main() {
http.HandleFunc("/user", getUserHandler)
http.ListenAndServe(":8080", nil)
}

SQL Injection

Use prepared statements with placeholders for parameters to prevent SQL injection. Avoid constructing SQL queries by concatenating strings. The database/sql package natively supports parameterized queries.

Example: Secure Database Query

package main

import (
"database/sql"
"log"
// Assuming a PostgreSQL driver
_ "github.com/lib/pq"
)

type User struct {
ID int
Name string
}

func GetUserByUsername(db *sql.DB, username string) (*User, error) {
// This query is safe from SQL injection because the username is passed
// as a parameter, not concatenated into the query string.
query := "SELECT id, name FROM users WHERE username = $1"

row := db.QueryRow(query, username)

var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
if err == sql.ErrNoRows {
return nil, nil // User not found
}
return nil, err
}
return &user, nil
}

func main() {
// Example usage
// db, err := sql.Open("postgres", "your-connection-string")
// if err != nil {
// log.Fatal(err)
// }
// defer db.Close()
//
// user, err := GetUserByUsername(db, "some_user")
// ...
}

Cross-Site Scripting (XSS)

Go's html/template package provides automatic contextual escaping, which is a powerful defense against XSS. It understands the context in which data is being inserted (HTML body, attribute, URL) and applies the appropriate escaping.

package main

import (
"html/template"
"net/http"
)

func searchHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
// The template package automatically escapes the query variable.
tmpl, _ := template.New("search").Parse(`<h1>Search Results</h1><p>You searched for: {{.}}</p>`)
tmpl.Execute(w, query)
}

func main() {
http.HandleFunc("/search", searchHandler)
http.ListenAndServe(":8080", nil)
}

If you run this example and provide a query like <script>alert('XSS')</script>, the output will be safely escaped to &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;.

Directory Traversal

A directory traversal (or path traversal) attack allows an attacker to access files and directories outside the web root directory. Sanitize file paths from user input to prevent this.

Example: Safe File Serving

package main

import (
"net/http"
"path/filepath"
)

func serveFileHandler(w http.ResponseWriter, r *http.Request) {
// Get the filename from the query parameter
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "Filename not specified", http.StatusBadRequest)
return
}

// Clean the path to prevent directory traversal.
// This resolves '..' and other path manipulations.
// For example, "../../secret.txt" becomes "secret.txt".
cleanedPath := filepath.Clean(filename)

// Construct the full path within a safe directory
safeDir := "/var/www/data"
fullPath := filepath.Join(safeDir, cleanedPath)

// Serve the file
http.ServeFile(w, r, fullPath)
}

func main() {
http.HandleFunc("/static", serveFileHandler)
http.ListenAndServe(":8080", nil)
}

Command Injection

Command injection vulnerabilities occur when an application passes unsanitized user input to a system shell. Use the os/exec package to invoke system commands, which avoids shell interpretation of arguments.

Example: Safe Command Execution

package main

import (
"log"
"os/exec"
)

func main() {
// Unsafe - vulnerable to command injection if `userInput` is `"; rm -rf /"`
// cmd := exec.Command("bash", "-c", "echo " + userInput)

// Safe - the arguments are passed directly to the command, not interpreted by a shell.
userInput := "some argument"
cmd := exec.Command("echo", userInput)

output, err := cmd.CombinedOutput()
if err != nil {
log.Fatalf("Command failed: %v\nOutput: %s", err, output)
}
log.Printf("Output: %s", output)
}

Static Analysis Security Testing (SAST)

Static analysis tools can help identify security flaws in your code before it runs.

gosec

gosec is a popular Go security checker that scans Go source code for potential security issues.

To install and run gosec:

go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...

gosec can detect a wide range of issues, including:

  • G101: Hardcoded credentials
  • G402: Look for TLS misconfigurations
  • G404: Insecure use of random numbers
  • G204: Subprocess launched with variable
  • G304: File path provided as taint input

Example of an issue gosec would flag:

// gosec will warn about using math/rand for security-sensitive operations.
// Use crypto/rand instead.
package main

import (
"fmt"
"math/rand"
"time"
)

func generateToken() string {
rand.Seed(time.Now().UnixNano())
// G404: Use of weak random number generator (math/rand instead of crypto/rand)
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}

Correction using crypto/rand:

package main

import (
"crypto/rand"
"fmt"
)

func generateSecureToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%x", b), nil
}

Go's Built-in Security Features

Go includes several tools and features to help you write more secure and reliable code.

Race Detector

Race conditions in concurrent code can lead to unpredictable behavior and security vulnerabilities. Go's race detector helps identify these issues at runtime.

To use the race detector, use the -race flag:

go test -race ./...
go run -race main.go
go build -race -o myapp

Example of a race condition:

package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// This line causes a race condition.
// Multiple goroutines are reading and writing to `counter` concurrently.
counter++
}()
}
wg.Wait()
fmt.Println("Final counter:", counter)
}

Running this with go run -race main.go will produce a detailed report.

Fixing the race condition with a mutex:

package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
var mu sync.Mutex
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Final counter:", counter)
}

Fuzz Testing

Fuzzing is an automated testing technique that provides randomized inputs to a function to uncover bugs, crashes, and potential security vulnerabilities. Instead of relying on predefined test cases, a fuzzing engine continuously generates new inputs based on the code's behavior, helping to discover edge cases that developers might miss.

Native Fuzzing (Go 1.18+)

As of Go 1.18, fuzzing is a first-class citizen in the Go toolchain. It is built into the go test command and provides a powerful way to test your code for robustness. For a detailed walkthrough, refer to the official Go fuzzing tutorial.

A fuzz test is a function named FuzzXxx that accepts a *testing.F. This object is used to manage the fuzzing process.

  1. Seed Corpus: You provide an initial set of interesting inputs, called the "seed corpus," using f.Add(). The fuzzing engine uses these as a starting point.
  2. Fuzz Target: You define a "fuzz target" function using f.Fuzz(). This function receives the testing object *testing.T and the randomly generated input arguments. Inside this function, you write the logic to test your code with the given inputs.

Example: Fuzzing a String Reversal Function

Let's expand on the previous reverse function example. A good property to test is that reversing a string twice should yield the original string.

package main

import (
"fmt"
"testing"
"unicode/utf8"
)

func reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, fmt.Errorf("input is not valid UTF-8")
}
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes), nil
}

func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err1 := reverse(orig)
if err1 != nil {
return
}
doubleRev, err2 := reverse(rev)
if err2 != nil {
t.Fatalf("Failed to reverse the reversed string: %v", err2)
}
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}

To run the fuzz test, use the -fuzz flag with go test. The fuzzer will run indefinitely until it finds a failing input or is manually stopped.

go test -fuzz=FuzzReverse

When a failing input is found, it is saved to a file in the testdata/fuzz/<FuzzTestName> directory, making it easy to reproduce the failure.

go-fuzz

Before native fuzzing was introduced, the Go ecosystem relied on the pioneering go-fuzz tool by Dmitry Vyukov. This tool heavily influenced the official implementation and has an impressive track record of finding hundreds of bugs in various Go projects.

go-fuzz works differently from the native implementation:

  • It requires installing separate command-line tools: go-fuzz-build and go-fuzz.
  • The fuzz target function has a specific signature: func Fuzz(data []byte) int. It takes a byte slice and returns an integer indicating the quality of the input (1 for interesting inputs, 0 for others).

Example: Fuzzing with go-fuzz

// This code is for illustration with go-fuzz
// and would be in a separate file, e.g., fuzz.go

package mypackage

import "fmt"

func Fuzz(data []byte) int {
s := string(data)
rev, err := reverse(s)
if err != nil {
return 0
}
doubleRev, err := reverse(rev)
if err != nil {
// This indicates a bug found by go-fuzz
panic(err)
}
if s != doubleRev {
panic(fmt.Sprintf("orig: %q, doubleRev: %q", s, doubleRev))
}
return 1 // The input is interesting
}

While native fuzzing is the standard for modern Go projects, go-fuzz remains a valuable tool and a significant part of Go's security history.

For more information, see the official Go documentation on fuzzing and the go-fuzz repository.

Go Vet

The go vet command examines Go source code and reports suspicious constructs. It can catch a variety of subtle bugs.

Run it on your project with:

go vet ./...

For example, go vet can warn about passing a lock by value, which can lead to subtle concurrency bugs.

Common Go Mistakes

Certain programming patterns in Go can lead to subtle bugs that have security implications. Being aware of these common mistakes, as documented in the Go Wiki, can help you write more robust and secure code.

Using Goroutines on Loop Iterator Variables (pre-Go 1.22)

A classic mistake in Go versions before 1.22 involves using loop iterator variables within a goroutine's closure. The loop variable is a single variable that is reused for each iteration. If a goroutine in the loop refers to this variable, it will likely observe the value of the variable from the last iteration, because the goroutines may not start executing until after the loop has completed.

Incorrect Usage:

package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
values := []string{"a", "b", "c"}

for _, v := range values {
wg.Add(1)
// This closure captures the loop variable `v`.
go func() {
defer wg.Done()
// By the time this runs, the loop is likely finished,
// and `v` will hold the last value, "c".
fmt.Println(v)
}()
}

wg.Wait()
}

The output will likely be:

c
c
c

Correct Usage:

To fix this, you should pass the loop variable as an argument to the goroutine. This creates a copy of the variable for each iteration.

package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
values := []string{"a", "b", "c"}

for _, v := range values {
wg.Add(1)
// Pass `v` as an argument to the closure.
go func(val string) {
defer wg.Done()
fmt.Println(val)
}(v) // The value of `v` is evaluated at the time of the call.
}

wg.Wait()
}

This version will correctly print a, b, and c (in a non-deterministic order).

Note on Go 1.22 and later: This behavior has been changed in Go 1.22. In newer versions, the loop variable is given a fresh binding for each iteration, so the original "incorrect" code now works as expected. However, it is still crucial to understand this behavior, as you may encounter it in older codebases.

Security Checklist

Here is a high-level checklist to keep in mind during development:

  • Dependency Management: Keep go.mod tidy and dependencies up-to-date.
  • Vulnerability Scanning: Regularly run govulncheck in your CI pipeline.
  • Input Validation: Validate and sanitize all user-provided input.
  • Parameterized Queries: Use parameterized queries to prevent SQL injection.
  • Contextual Escaping: Use html/template for web output to prevent XSS.
  • Path Sanitization: Clean file paths from user input to prevent directory traversal.
  • Secure Command Execution: Use os/exec with separate arguments to prevent command injection.
  • SAST: Integrate gosec into your development workflow.
  • Concurrency: Use the -race flag during testing to detect race conditions.
  • Fuzz Testing: Write fuzz tests for functions that handle complex, untrusted input.
  • Least Privilege: Run applications with the minimum required permissions.
  • Secrets Management: Do not hardcode secrets. Use environment variables or a secrets management system.

Further Reading and References

By following these best practices and utilizing the tools available in the Go ecosystem, you can significantly improve the security and reliability of your applications.