Go has taken the programming world by storm. When it recently passed its ten-year anniversary, estimates suggested as many as 2 million people use the language. As that number continues to grow, common mistakes have emerged that can lead to bugs and security vulnerabilities. In this article, I will address some of them so you can arm yourself with the knowledge to write more robust, secure Go applications, and avoid SQL injections and other security issues.
One of the first things most people notice when they start learning Go is the verbose error handling.
This leads many Go newcomers to try to find shortcuts, up to and including completely skipping (often unintentionally) error checking:
Although this may make for less code to read, it is incredibly dangerous.
It can lead to subtle bugs or even crashes, as in this real-world example:
In this simple example, if we encounter any error connecting to the MySQL server, the
db variable will be
nil, which in turn will cause the call to
db.Query to panic.
And what’s even worse than a potential panic is code that may silently ignore errors and thus do entirely the wrong thing. Let’s imagine a very simplified money transfer function that withdraws money from one account, then deposits it in another:
This implementation suffers from a very serious (and hopefully obvious) security flaw: there’s no check to make sure the withdrawal is successful. Perhaps there is a database error. Perhaps the “from” account lacks sufficient funds or doesn’t even exist. Without checking the error condition, the bank will attempt the deposit anyway, possibly depositing money that isn’t there. The opposite is also possible: the withdrawal may succeed, and the deposit may silently fail, leading to money vanishing even faster than it normally does on rent day.
To avoid this entire class of bug, follow one simple rule: always handle all errors. And the surest way to be sure not to accidentally forget to check an error in your code is to use a linter. I’ll talk more about that in a moment.
Continuing with database examples, let’s talk about user inputs. One of the most common ways we use databases in applications is by storing and updating user-provided data. The trouble is, unless care is taken, we can inadvertently give our users the ability to do some really nasty things to our data. Let’s use an example to demonstrate.
While this code works (and even does proper error checking, FTW), it hides an insidious bug. Imagine that one of your application’s users said their name was:
'); DROP TABLE users; --
This would cause our application to execute the following SQL:
INSERT INTO TABLE users (fullname) VALUES (''); DROP TABLE users; --')
users table is no more. (Note that not all database engines allow multiple statements in a single
Exec() call, but even those that don’t are susceptible to this type of vulnerability with only slightly more complicated user input.)
These vulnerabilities are called SQL injections. SQL injections are some of the most pervasive and most dangerous types of attack vectors out there. Fortunately, safeguarding against them is pretty straightforward in most cases: SQL variable substitution.
This passes the user-provided
fullname variable to the SQL server as a query parameter. This way, there’s no possibility the server may accidentally execute the user input as though it were a SQL command. With this approach, it’s perfectly safe to include any value whatsoever in the
How to identify vulnerabilities
One simple way to identify vulnerabilities in code is through peer code review. I have always been a strong advocate of this approach. It’s best to require at least two sets of eyes on every line of code before it is merged into mainline.
Naturally, though, at least one of those sets of eyes must know what to look for when it comes to security vulnerabilities. For particularly security-sensitive areas of code, such as authentication routines or the handling of personal identifiable information (PII), it is often wise to have your team’s foremost security expert go through an extra security review.
Beyond code review, implementing a security-in-depth set of tooling can help you identify, block, and/or remediate vulnerabilities at every stage of the SDLC. Let’s take a look at some of the options.
Linters and static analysis tools
Static analysis (often simply called “linting” even though that’s technically a subset of static analysis) is the process of examining a program’s source code without actually executing it. This can be done for any number of reasons, from cataloging function usage to finding performance optimizations. For the purposes of this article, it’s mostly useful as a way to find bugs and security vulnerabilities at the code level.
Go has been built from the ground up with strong tooling in mind. This applies to static analysis as well. The same core logic that parses your Go source code during compilation easily integrates into custom analysis tools. This means that if you’re so inclined, you could write your own static analysis tool for Go without Herculean effort. But more likely you’ll want to take advantage of some of the existing tools out there. For Go, this is made easy by the free open-source golangci-lint tool, which works by running multiple linters and static analysis tools simultaneously against your entire Go project. It then reports any errors it finds to the console or through your IDE.
I encourage you to view the list of all linters supported by the tool and select the ones most relevant for your project. If you’re unsure, you can start with the standard list. It’s easy to add new linters to your own configuration as you desire. Then be sure to install golangci-lint in your CI pipeline as well as local integration with your IDE or development environment. Installation is easy!
Another powerful tool the Go toolchain gives us is the race detector. A data race occurs any time one goroutine attempts to write to a memory location that at least one other goroutine is accessing at the same time. This class of bug is incredibly common in concurrent systems and is notoriously difficult to debug. An undetected data race can lead to undefined behavior, up to and including program crashes. Even more dangerous are data races that don’t cause a crash. They may end up altering data in unexpected and unnoticed ways.
Let’s consider a simplified example. Suppose you’re writing a web application that allows users to top up their account balances with a simple function like this:
It looks innocent enough. But suppose your user, with an initial balance of 0, hits the “Top Up” button on the web site to add a credit of 20 at the same time another user sends a gift voucher worth 50. This means
AddBalance will be called twice simultaneously:
AddBalance(50). With no synchronization between the goroutines making these two calls, the final balance on the account is undefined. If both instances of
AddBalance read the initial balance of 0 simultaneously and then add their own balance, only one of the additions will be effective, and the final balance will be either 20 or 50, not 70 as it should be.
This is where the race detector comes in handy. It adds some extra checks to the compiled-in Go runtime that will detect these conditions and explicitly panic (with detailed stack traces of every goroutine). When testing a Go package designed to be usable concurrently, the rule of thumb is to have dedicated tests of the concurrent usages, involving multiple goroutines. The more goroutines you have in those concurrency tests, the more likely you are to see a race condition happen and have it detected by the race detector. I encourage you to always have an extra test pass with the race detector:
go test -race ./...
In some cases, you may also want to build your test binaries with the race detector. But be aware that the race detector makes execution slower, so it’s advisable to avoid it for production builds.
To guard against this type of problem, synchronization is required. This can be done in a number of ways, but one broadly applicable method is to protect a critical section with a sync.Mutex, so that only one goroutine at a time can have access to it. You can read more about Go synchronization primitives at https://golang.org/pkg/sync/
Dynamic application security testing
Dynamic application security testing (or DAST) is an approach in which your application is actively probed by a tool in an attempt to detect potential security vulnerabilities. Many such tools exist, but for Go, zaproxy is one of the more popular free choices available.
DAST tools will simulate traffic to try and detect vulnerabilities beyond the pure code level. One complaint, however, which is common both to DAST and static analysis tools, is the number of false positives they produce. Neither DAST nor SAST tools have any context regarding your application, so they will often uncover vulnerabilities that are not exploitable or irrelevant. This can be especially painful for large or legacy projects where there may be potentially thousands of false positives, and taking the time to disable false warnings on a case-by-case basis may not be practical.
Active security monitoring and protection tools
Another approach used by many modern development and DevOps teams is the use of real-time protection tools. These tools move beyond the code layer to examine HTTP requests and function executions to identify suspicious payloads (WAFs) and/or vulnerability exploitation attempts (RASPs).
Sqreen is one such tool that delivers both. By adding Sqreen’s microagent to your applications, it can perform real-time security monitoring as users interact with your applications. It will notify you of any vulnerabilities as they are exploited and block attacks without false positives.
For example, in the current scenario of a SQL injection, Sqreen automatically protects Go’s SQL package database/sql functions in order to check every SQL query string against values coming from the HTTP request. If one is found, the SQL query is immediately aborted before it actually happens and the HTTP request is automatically blocked. The attack details are then logged into the dashboard.
You can read more about how Sqreen for Go works at https://blog.sqreen.com/dynamic-instrumentation-go/
The world of security vulnerabilities is constantly changing, so it’s important to stay vigilant. Don’t ignore the problem just because it seems overwhelming. On the other hand, don’t let yourself or your team become overwhelmed. By leveraging some of the modern tools available and creating a security-in-depth stack, you can immediately improve security and gain some peace of mind.
Implementing something like a security linter and Sqreen will let you catch code-level bugs pre-production, and block attacks in your application (including NoSQL injections, SQL injections, or XSS) without you having to take any action or deal with complicated configuration. Installing Sqreen for Go is straightforward, and since it comes with a free trial, why not give it a try?
This post was written by Jonathan Hall. Jonathan is a DevOps consultant and blogger who helps small teams benefit from DevOps best practices. Follow him on Twitter, or check out his web site.