Go has a reputation for safe defaults, and much of it is earned: database/sql supports bind parameters, html/template escapes by context, and the standard library is small enough to read. The bugs that remain in Go web services are rarely exotic. They are the same six shapes, in net/http and in the popular routers (Gin, Echo, Chi, Fiber, gorilla/mux), and each one looks reasonable in a diff.
This post goes through them with a vulnerable snippet and a fix for each.
1. A route registered outside the auth middleware
Go has no framework-wide authentication. Auth is a middleware that wraps a handler or a group, so its coverage is decided by where each route is registered.
// Vulnerable: /admin/export sits on the root mux, not the protected one
mux := http.NewServeMux()
admin := http.NewServeMux()
admin.HandleFunc("GET /admin/users", listUsers)
mux.Handle("/admin/", requireAdmin(admin))
mux.HandleFunc("GET /admin/export", exportAll) // added later, no wrapperWith Go 1.22's pattern matching, the more specific GET /admin/export pattern wins over /admin/, so the new route bypasses requireAdmin entirely. Gin and Echo have the same failure with r.GET called on the engine instead of the admin group.
The fix is structural: register protected routes only on the protected group, and make the default deny.
// Fixed: every /admin route lives on the wrapped mux
admin.HandleFunc("GET /admin/export", exportAll)A useful test is to walk your registered routes and assert that each one either passes through the auth middleware or appears in an explicit public list.
2. SQL built with fmt.Sprintf
database/sql supports placeholders, but fmt.Sprintf is muscle memory for Go developers.
// Vulnerable
id := r.URL.Query().Get("id")
q := fmt.Sprintf("SELECT name FROM users WHERE id = '%s'", id)
rows, err := db.QueryContext(ctx, q)
// Fixed: the driver sends id separately
rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE id = $1", id)The placeholder syntax is the driver's: $1 for PostgreSQL drivers, ? for MySQL and SQLite. Identifiers (column names in ORDER BY, table names) cannot be bound, so map them from a fixed set of allowed values in code. Query builders and sqlc help, but only when the dynamic parts go through their parameter APIs.
3. Path traversal with filepath.Join
filepath.Join cleans a path; it does not keep it inside a directory.
// Vulnerable: name = "../../etc/passwd" climbs out of baseDir
name := r.URL.Query().Get("file")
f, err := os.Open(filepath.Join(baseDir, name))The Go team's own post on traversal-resistant file APIs uses this exact line as the bad example. Two fixes exist in the standard library:
// Go 1.20+: reject anything that is not a local path
if !filepath.IsLocal(name) {
http.Error(w, "invalid file name", http.StatusBadRequest)
return
}
// Go 1.24+: open inside a root, which also refuses symlink escapes
f, err := os.OpenInRoot(baseDir, name)filepath.IsLocal checks the string only, so a symlink inside baseDir can still point outside it. os.OpenInRoot and os.Root refuse escapes through .. and through symlinks. If you serve a whole directory, http.FileServer with http.Dir already rejects .. in request paths; the problem is custom download handlers. Archive extraction (zip slip) is the same bug with the name coming from inside the archive.
4. SSRF through http.Get
A webhook tester, a link preview or an "import from URL" field passes a user URL to the HTTP client:
// Vulnerable: the caller chooses the destination
resp, err := http.Get(r.FormValue("url"))The server can reach your internal services and the cloud metadata address; the caller cannot, until this line lends them the server. Prefer a key that selects a URL from a server-side table. When you must accept arbitrary URLs, check the resolved address at connect time with a custom net.Dialer Control function that refuses loopback, private and link-local ranges, and stop the client from following redirects with a CheckRedirect that returns http.ErrUseLastResponse. The reasoning is the same as in our SSRF in Node.js post: check the address the socket connects to, not a string.
5. No timeouts, on either side
http.ListenAndServe(addr, h) builds a server with no read or write timeouts, so a client that sends headers one byte at a time can hold a connection open indefinitely. On the client side, http.DefaultClient has a zero Timeout, and the net/http docs say a zero timeout means no timeout.
// Fixed server
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
// Fixed client
client := &http.Client{Timeout: 10 * time.Second}Pick values that fit your slowest legitimate request; streaming endpoints may need per-handler deadlines through http.ResponseController. Cap request bodies with http.MaxBytesReader while you are there.
6. text/template where html/template belongs
The two packages share an API, which is the trap. The html/template docs say it "should be used instead of text/template whenever the output is HTML", because its escaping is contextual: it knows whether a value lands in HTML text, an attribute, a URL or JavaScript.
// Vulnerable: text/template writes the value verbatim
import "text/template"
tmpl := template.Must(template.New("p").Parse(`<p>Hello {{.Name}}</p>`))
// Fixed: same code, different import
import "html/template"Even with html/template, the type template.HTML marks content as trusted and skips escaping. The docs warn that it is "included verbatim", so converting user input to template.HTML reintroduces the bug. Search for template.HTML(, template.JS( and template.URL( and check where each value comes from.
A review checklist for Go services
| Check | What to look for |
|---|---|
| Auth coverage | Routes registered on the root mux or engine instead of the protected group |
| SQL | fmt.Sprintf or + building the first argument of Query, Exec, QueryRow |
| Files | filepath.Join with request input and no IsLocal or os.Root |
| Outbound requests | http.Get, client.Do with a URL from the request |
| Timeouts | http.ListenAndServe, http.DefaultClient, http.Get in server code |
| Templates | text/template producing HTML, template.HTML(...) on user data |
What Vulkro checks in Go
Vulkro Core detects Go projects from go.mod, maps endpoints for net/http, Gin, Echo, Chi, Fiber and gorilla/mux, and gives each endpoint an auth tier that feeds the OWASP API Top 10 checks such as broken function-level authorization. Its taint pass follows request values (r.URL.Query(), r.PostFormValue, headers and path parameters) into SQL query construction, os/exec, filesystem paths, outbound request URLs, redirects, HTML output and server-side templates; the SQL rule is GO-TAINT-SQL-001. Zip slip and response-header injection have their own Go checks, and go.mod dependencies plus the Go standard library version are matched against the local CVE bundle. The Go docs page lists the coverage, and our published benchmark lists the Go cases we still miss.
FAQ
Is Go safe from SQL injection by default?
Only when you use bind parameters. database/sql supports placeholders, but a query string built with fmt.Sprintf or concatenation is as injectable in Go as in any other language.
Does filepath.Clean prevent path traversal in Go?
No. filepath.Clean and filepath.Join normalize .. but still allow a path that climbs above your base directory. Use filepath.IsLocal (Go 1.20+) to reject such names, or os.OpenInRoot (Go 1.24+) to confine the open itself.
What timeouts should a Go HTTP server set?
At least ReadHeaderTimeout, plus ReadTimeout, WriteTimeout and IdleTimeout sized for your slowest legitimate request. http.ListenAndServe sets none of them.
See how this looks on your own service: Start a 14-day trial. Related reading: broken access control is still the top breach.