Skip to main content

What fuzzing does

Fuzz testing feeds random inputs to your code and watches for crashes, panics, or assertion failures. It finds bugs that humans do not think to test for, such as malformed UTF-8, integer overflows, and nil pointer dereferences. Go has built-in fuzzing since Go 1.18. Unkey fuzz tests must use pkg/fuzz on top of Go’s native fuzzing API. pkg/fuzz provides deterministic seeds and converts fuzzer-controlled byte slices into typed values without hiding inputs from the coverage-guided fuzzer. Every fuzz test must call fuzz.Seed(f), accept data []byte, create a fuzz.Consumer with fuzz.New(t, data), and derive generated values from that consumer. Do not fuzz typed parameters directly, and do not use math/rand or ad hoc byte slicing inside fuzz tests. When the fuzzer finds a failure, Go saves that input so the bug becomes a regression test.

When to write fuzz tests

Fuzz tests are best for code that processes untrusted input: parsing, encoding, decoding, validation, and cryptographic operations. They are less useful for business logic with complex preconditions.

Writing your first fuzz test

Start a fuzz test by naming the property it protects. Seed inputs are examples of the property, not the property itself. Add a docstring when the property is a security boundary, parser invariant, or regression from a production bug.
import "github.com/unkeyed/unkey/pkg/fuzz"

// FuzzParseConfigPreservesTimeoutBounds guarantees that every accepted config
// produces a non-negative timeout. Invalid config may be rejected, but accepted
// config must be safe to execute.
func FuzzParseConfig(f *testing.F) {
    fuzz.Seed(f)

    f.Fuzz(func(t *testing.T, data []byte) {
        c := fuzz.New(t, data)
        input := c.String()

        cfg, err := ParseConfig(input)
        if err != nil {
            return
        }

        require.NotNil(t, cfg)
        require.GreaterOrEqual(t, cfg.Timeout, 0)
    })
}

Skipping invalid inputs

Use t.Skip() for inputs that do not meet required preconditions. pkg/fuzz also calls t.Skip() when the consumer runs out of bytes, which keeps every generated value tied to fuzzer-controlled input.
if len(key) != 16 && len(key) != 24 && len(key) != 32 {
    t.Skip("invalid key size")
}

Testing security properties

Use fuzzing to validate tamper detection and authentication guarantees. Document security properties in the fuzz test docstring. A future reader must know whether the fuzzer protects parser safety, signature verification, canonical encoding, authorization boundaries, or another guarantee.

Running fuzz tests

During normal test runs, fuzz tests execute only with their seed corpus:
mise exec -- bazel test //pkg/encryption:encryption_test
To fuzz locally:
mise exec -- go test -fuzz=FuzzParseConfig -fuzztime=30s ./pkg/config/
When fuzzing finds a failure, Go saves the input to testdata/fuzz/<TestName>/ so it becomes part of the seed corpus.

What to do when fuzzing finds a bug

Write a deterministic unit test for the failing input, then fix the bug. Keep the fuzz corpus in testdata to prevent regressions.

Bazel configuration

Fuzz tests live in regular go_test targets. Add //pkg/fuzz to the target dependencies, and include the fuzz corpus with data = glob(["testdata/**"]) when the test has saved failure inputs.