Fuzzing Go APIs for SQL Injection

August 30, 2022 ~ 8 min read
Tutorial

Fuzzing is a powerful tool that finds subtle bugs and vulnerabilities by running millions of procedurally generated tests on your code. It’s been used to great effect on self-contained pieces of code like parsers and libraries, but can also be incredibly effective when testing larger systems with multiple components.

In this post we’ll walk through some techniques for fuzz testing a REST API written in Go and backed by a SQLite database. With a single test, we’ll simulate how millions of users may interact with our API and uncover SQL injection, logical, and data integrity bugs that would’ve otherwise been shipped to production.

Note: This is the third in a series of posts about Go’s new fuzz testing tools. If you’re new to fuzzing with Go, we recommend starting with our Go Fuzzing Basics post first.

The Case for Fuzzing REST APIs

The conventional wisdom for fuzzing is to rigorously test all trust boundaries in your code. Put simply, any point in your codebase where data is taken from an untrusted source such as a user, and processed by your application is worth fuzzing. REST APIs are some of the largest trust boundaries in modern web apps, and need to be secure from attacks like SQL Injection and Denial of Service, as well as logical state-related issues.

It’s impossible to come up with and build a separate test for every potential user interaction, especially when considering sequences of API calls users can make to put your data model into unexpected states. Fuzzing allows us to write a single test that can generate malicious and destructive API call sequences, covering a wide array of potentially problematic code paths.

API Project Layout

Let’s start by taking a quick look at the REST API we’ll be testing: A simple CRUD API that simulates a basic phonebook.

Users consisting of names, email addresses and phone numbers can be created, viewed and deleted, and their phone numbers can be updated.

All of the code for this project can be found at https://github.com/fuzzbuzz/go-fuzzing-tutorial, under 03-rest-api. The project is laid out in a simple structure:

.
├── cmd
│   └── server
│   	└── main.go
└── handlers
	├── db.go
	├── handlers.go
	├── handlers_sequence_test.go
	├── handlers_test.go

The main.go under cmd/server handles setting up the HTTP server, wiring up routes to their respective handlers, and initializing & migrating the SQLite database. We’re using SQLite to keep things simple, but you can of course use any type of database in the same way.

The handlers/ directory contains the meat of the project - handlers for creating, deleting, updating and getting a User, as well as unit tests in the _test.go files.

We won’t go over every file in detail, but the key takeaway is that there are four HTTP handlers: AddUser, GetUser, DeleteUser, and UpdateUser. To get an idea of how they are used, we can take a look at TestInsertUser in handlers/handlers_test.go:

func TestInsertUser(t *testing.T) {
    db := initDb(t)
    handlers := NewHandlers(db)

    // Set up input data
    input := AddUserRequest{
   	 User{
   		 Name:    	"John Smith",
   		 Email:   	"[email protected]",
   		 PhoneNumber: "+1 650 750 8505",
   	 },
    }

    // Call AddUser with this data
    r := callAddUser(t, handlers, input)
    if r.Result().StatusCode != 200 {
   	 t.Fatal("Expected insert user to work")
    }

    // Call GetUser with the same email
    r = callGetUser(t, handlers, input.Email)
    if r.Result().StatusCode != 200 {
   	 t.Fatal("Expected to get user back")
    }

    // Unmarshal the json response and check if the data is the same
    result := &GetUserResponse{}
    err := json.Unmarshal(r.Body.Bytes(), result)
    if err != nil {
   	 t.Fatal("Expected proper json response:", err)
    }
    if result.User.Name != input.Name {
   	 t.Fatal("Expected name", input.Name, "got", result.User.Name)
    }
}

This test does the following:

  1. Initializes the database using the initDB test helper, which sets up a full in-memory SQLite instance - no mocking required, and allows us to test the API end-to-end.
  2. Creates and populates a User struct
  3. Adds the user to the database by calling AddUser
  4. If AddUser returns a 200, it calls GetUser and validates that the returned value matches the User we created in step 3.

While this is a useful test for validating that our API isn’t completely broken, it’s limited by the hardcoded values for the user data - since this application will be exposed over the internet, it would be great to have some more robust testing for undefined data.

You could manually create more tests to check different code paths, but you’d likely miss a bunch of edge cases and it’d be awfully boring. That’s where fuzzing comes in handy.

Fuzzing our API for SQL Injection

A good way to write your first couple fuzz tests is to start from existing unit tests. So, let’s take TestInsertUser, and write FuzzInsertUser:

func FuzzInsertUser(f *testing.F) {
    // Initialize the DB outside of the fuzz loop to save time
    db := initDb(f)
    // Set up our handlers with the in-memory test DB
    handlers := NewHandlers(db)

    // Add the example inputs from the unit test
    f.Add("[email protected]", "John Smith", "+1 234 567 8901")

    f.Fuzz(func(t *testing.T, email, name, phoneNumber string) {
   	 // Validate that all of the inputs are correct UTF-8 strings.
	 if !utf8.ValidString(email) || !utf8.ValidString(name) || !utf8.ValidString(phoneNumber) {
   		 return
   	 }
   	 defer func() {
   		 // We don't check the result, we just want to make sure the user is gone
   		 // after each iteration of the test, so we start off with an empty DB
   		 callDeleteUser(t, handlers, email)
   	 }()

   	 // Set up input data
   	 input := AddUserRequest{
   		 User{
   			 Email:   	email,
   			 Name:    	name,
   			 PhoneNumber: phoneNumber,
   		 },
   	 }

   	 // Call AddUser with this data
   	 r := callAddUser(t, handlers, input)
   	 if r.Result().StatusCode != 200 {
   		 // This is ok, we want non-200 status codes for invalid data
   		 // We just shouldn't continue with the rest of the test
   		 return
   	 }

   	 // Here, the user has been inserted into the db
   	 // Call GetUser with the same email
   	 r = callGetUser(t, handlers, input.Email)
   	 if r.Result().StatusCode != 200 {
   		 t.Fatal("Expected to get user back, got", r.Result().StatusCode)
   	 }

   	 // Unmarshal the json response and check if the data is the same
   	 result := &GetUserResponse{}
   	 err := json.Unmarshal(r.Body.Bytes(), result)
   	 if err != nil {
   		 t.Fatal("Expected proper json response:", err)
   	 }
   	 if result.User.Name != input.Name {
   		 t.Log("Expected:", []byte(input.Name), "got", []byte(result.User.Name))
   		 t.Fatal("Expected name", input.Name, "got", result.User.Name)
   	 }
    })
}

It maintains the structure of the unit test, with a key difference: rather than hard-coding email, name, and phoneNumber values, we leave the generation of these inputs up to the fuzzer. The result of this is, rather than testing that our Create and Get sequence works for a single input, we’re asserting that it works for all possible user inputs.

Run the fuzz test from the 03-rest-api directory:

go test -run FuzzInsertUser -fuzz FuzzInsertUser ./handlers
fuzz: elapsed: 0s, gathering baseline coverage: 0/167 completed
fuzz: minimizing 3506-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 42/167 completed
--- FAIL: FuzzInsertUser (0.15s)
	--- FAIL: FuzzInsertUser (0.00s)
    	handlers_test.go:148: Expected to get user back, got 404

	Failing input written to testdata/fuzz/FuzzInsertUser/41d2503b0ce697a867179dd69d6067cfd7067d17b76c0d3345d6af84a430b2b6
	To re-run:
	go test -run=FuzzInsertUser/41d2503b0ce697a867179dd69d6067cfd7067d17b76c0d3345d6af84a430b2b6
FAIL
exit status 1
FAIL    github.com/fuzzbuzz/go-fuzzing-tutorial/03-rest-api/handlers    0.152s

In under a second, our fuzz test found a bug in our code. It found an input that caused AddUser to return a 200, but somehow failed to save the data in our DB, causing the GET to fail. Take a look at the input:

cat handlers/testdata/fuzz/FuzzInsertUser/41d2503b0ce697a867179dd69d6067cfd7067d17b76c0d3345d6af84a430b2b6
go test fuzz v1
string("0")
string("0")
string("'")

We can see that the fuzzer shrunk each of the inputs down to a single character. The quote in the phone number field is suspect - let’s take a look at the AddUser handler:

// ...
   	_, err = h.db.Exec(fmt.Sprintf("insert into users(name, email, phone_number) values ('%s', '%s', '%s');",
   	 request.Name, request.Email, request.PhoneNumber))
    if err != nil && err == sqlite3.ErrConstraintUnique {
   	 // return a 400 if the user has already inserted this email
   	 c.JSON(http.StatusBadRequest, gin.H{
   		 "error": "email already added",
   	 })
   	 return
    }
// ...

It turns out, we’ve actually uncovered two bugs here:

  1. We’re not sanitizing our database inputs, opting instead for basic string interpolation to build up our query! This leaves the database exposed to a SQL injection attack, which could allow malicious users to run raw queries directly on the database, expose sensitive information, or delete data.
  2. We’re not checking errors correctly: any error other than a unique constraint error is silently ignored, meaning any failed queries get silenced while the handler returns a 200 status.

The fuzzer discovered all on its own that a quote was significant, and then used that character to cause the SQL error, and provided us with a simple input that reproduces the behavior.

To fix this, swap the query to use SQL argument interpolation which will properly sanitize the input, and make sure to add a catch-all error check at the end:

// ...
	_, err = h.db.Exec(fmt.Sprintf("insert into users(name, email, phone_number) values (?, ?, ?);"),
   	 request.Name, request.Email, request.PhoneNumber)
    if err != nil {
   	 if err == sqlite3.ErrConstraintUnique {
   		 // return a 400 if the user has already inserted this email
   		 c.JSON(http.StatusBadRequest, gin.H{
   			 "error": "email already added",
   		 })
   	 } else {
   		 // return a 500 otherwise, since it's an error from the DB we don't handle
   		 c.JSON(http.StatusInternalServerError, gin.H{
   			 "error": err.Error(),
   		 })
   	 }
   	 return
    }
// ...

After running the test again, we’ll see that the bugs are fixed.

With just a simple test, we were able to find and reproduce two of the most common bugs in REST APIs: SQL Injection, and unhandled errors.

Fuzzing the API for Logic Bugs

While our previous example is a useful technique, we’ve only tested a single sequence of calls. Writing tests for each of the (infinitely many) possible sets of call sequences for a CRUD app would be tedious and time-consuming. Luckily, we can easily extend the above test to generate arbitrary sequences of Create, Read, Update and Delete API calls.

FuzzSequenceHandlers can be found in handlers/handers_sequence_test.go. What follows is an example of the test’s skeleton, to save space (you can view the full test here):

const (
    AddUser = iota
    GetUser
    UpdateUser
    DeleteUser
)

type State struct {	
    InDB	bool
    Updated bool
}

func FuzzSequenceHandlers(f *testing.F) {
    db := initDb(f)

    handlers := NewHandlers(db)

    // Add an example that adds a user, updates them,
    // checks that the update worked, and then deletes
    f.Add([]byte{0, 2, 1, 3}, "[email protected]", "John Smith",
      "+1 234 567 8901", "+9 876 543 2109")

    f.Fuzz(func(t *testing.T, operations []byte, email, name, number1, number2 string) {
   	 if len(email) == 0 {
   		 return
   	 }
   	 if !utf8.ValidString(email) || !utf8.ValidString(name) ||
	    !utf8.ValidString(number1) || !utf8.ValidString(number2) {
   		 return
   	 }
   	 defer func() {
   		 // We don't check the result, we just
		 // want to make sure the user is gone
   		 callDeleteUser(t, handlers, email)
   	 }()
   	 state := State{}

   	 // Limit the number of API calls we make in one go to 10
   	 if len(operations) > 10 {
   		 operations = operations[:10]
   	 }

   	 t.Log("Running", len(operations), "operations")
   	 for i, operation := range operations {
   		 t.Log("Operation:", i+1)
   		 switch operation % 4 {
   		 case AddUser:
   			t.Log("Adding")
			// code that calls AddUser with email, name, number1
   			t.Log("Add successful")
   			state.InDB = true
   			state.Updated = false
   			state.Log(t)
		 case GetUser:
   			t.Log("Getting")
			// Code that calls GetUser
			// Expects either a 200 or error response, depending on current state
			// Also checks if the phone number was updated depending on current state
   			t.Log("Get successful")
   			state.Log(t)
   		 case UpdateUser:
   			t.Log("Updating")
			// Code that calls UpdateUser with number2
			// Expects either a 200 or error response, depending on current state
   			state.Log(t)
		 case DeleteUser:
   			t.Log("Deleting")
			// Code that calls DeleteUser, expects a 200 or error depending on if user is currently in DB
			state.InDB = false
   			state.Updated = false
   			state.Log(t)
   		 }
   	 }
    })
}

As you can see, our fuzz test accepts two new parameters: a byte array named operations a second phone number we can use to test UpdateUser. The operations array is where the magic of this test lies: it allows us to create an array of numbers, which we then map to operations. So, if an element in the byte array, modulo 4, is equal to 0, we call AddUser. If it’s equal to 1, we call GetUser, and so on, looping through until we run out of numbers or arrive at our maximum.

Run this test with:

go test -run FuzzSequenceHandlers -fuzz FuzzSequenceHandlers
fuzz: fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 8 workers
fuzz: minimizing 77-byte failing input file
fuzz: elapsed: 1s, minimizing
--- FAIL: FuzzSequenceHandlers (0.70s)
	--- FAIL: FuzzSequenceHandlers (0.00s)
    	handlers_sequence_test.go:95: Running 2 operations
    	handlers_sequence_test.go:97: Operation: 1
    	handlers_sequence_test.go:154: Updating
    	handlers_sequence_test.go:171: Update successful
    	handlers_sequence_test.go:66: State, InDB: true Updated: true
    	handlers_sequence_test.go:97: Operation: 2
    	handlers_sequence_test.go:122: Getting
    	handlers_sequence_test.go:128: Expected to get user back, got 404
    
	Failing input written to testdata/fuzz/FuzzSequenceHandlers/a5b01abf7948a05ad8d65b3a834665f04bdd874a5d06191a5e980b8e46f70e8c
	To re-run:
	go test -run=FuzzSequenceHandlers/a5b01abf7948a05ad8d65b3a834665f04bdd874a5d06191a5e980b8e46f70e8c
FAIL
exit status 1
FAIL    github.com/fuzzbuzz/go-fuzzing-tutorial/03-rest-api/handlers    0.708s

Within 0.70s, our fuzz test found a buggy sequence of calls and reduced it down to a two-call sequence: an Update, followed by a Get. The Update should have failed, since there was no call to AddUser first, but instead it returned a 200 response, since GetUser was called afterwards.

Taking a look at UpdateUser in handlers/handlers.go, we can see that we missed a key error case:

// ...
  _, err = h.db.Exec("update users set phone_number = ? where email = ?;", request.PhoneNumber, wantEmail)
    if err != nil {
   	 if err != sql.ErrNoRows {
   		 c.JSON(http.StatusBadRequest, gin.H{
   			 "error": err.Error(),
   		 })
   	 }
   	 return
    }
// ...

The handler doesn’t contain the case for actually returning a 404 properly, which resulted in this handler returning a 200 when attempting to update a user that does not exist.

Without a fuzz test, this issue would have likely been missed in code review, and would probably have made its way to production causing complicated, difficult-to-debug problems for users.

Using a fuzzer in this manner is a simple but effective way to simulate how users can interact with your API, and catch bugs that would otherwise go undetected during development.

Fuzzing Around the HTTP Framework

One thing you may have noticed is that all of the function calls made in these tests completely skip the HTTP layer. Instead, we opted to directly call our handler functions from the unit test.

This is done to optimize the speed of the tests - it’s much more efficient to execute a function call, than to make an HTTP request, even if it’s on the same machine. In fact, we’re not interested in testing the HTTP framework since we can assume they are already well tested, so by fuzzing the handlers directly, we’re able to test our underlying logic more efficiently.

API Fuzzing in the Real World

The average Go API in production is significantly more complex than the toy example presented, making it even more likely that issues like unhandled errors, SQL injection, and logical bugs go undetected by static analysis or unit tests. Developers can save time and energy by writing fewer, more robust API fuzz tests that simulate real-world interactions and end up uncovering more bugs.

The Go fuzzer is flexible and can often be adapted to very complex codebases, due to its ability to generate structured data. We encourage you to try writing a fuzz test for your code - it’s often a lot easier than you may think. And, if you hit any roadblocks or would like some pointers, come ask for some help in our Discord.

By the way - we just opened sign ups for the Fuzzbuzz Beta, so anyone can start fuzzing code in CI/CD for free. Create your account here.