Go (Golang) Web Application Security Testing: Vulnerabilities and Attack Vectors
Go's reputation for simplicity and performance has made it the default choice for cloud-native backends, microservices, and infrastructure tooling. The language's design eliminates entire categories of memory safety bugs, but it introduces its own class of security pitfalls — particularly around its standard library's HTTP defaults, template engine behaviour, and the goroutine concurrency model.
This guide covers the vulnerability classes most relevant to Go web applications, how to identify them during a security review, and the specific patterns to probe when testing Gin, Echo, or Fiber-based services.
SQL Injection in database/sql
Go's database/sql package provides parameterised query support, but only when developers use it correctly. The most common injection pattern comes from string concatenation rather than placeholders:
// VULNERABLE — direct string interpolation
func getUser(db *sql.DB, username string) (*User, error) {
query := fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", username)
row := db.QueryRow(query)
// ...
}
// SECURE — parameterised query
func getUser(db *sql.DB, username string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE username = ?", username)
// ...
}
During code review, search for these patterns:
grep -rn "fmt.Sprintf.*SELECT\|fmt.Sprintf.*INSERT\|fmt.Sprintf.*UPDATE\|fmt.Sprintf.*DELETE" .
grep -rn "fmt.Fprintf.*db\|string(.*query" .
grep -rn "QueryRow(fmt\|Query(fmt\|Exec(fmt" .
ORM-Level Injection (GORM, sqlx)
GORM's Where method accepts raw SQL when passed a string — a pattern developers use for complex filters but which becomes injectable when user input is interpolated:
// VULNERABLE — raw SQL condition with user input
db.Where("username = '" + username + "'").First(&user)
// Also vulnerable — using fmt.Sprintf inside Where
db.Where(fmt.Sprintf("role = '%s'", role)).Find(&users)
// SECURE — positional placeholder
db.Where("username = ?", username).First(&user)
// SECURE — named placeholder
db.Where("username = @username", sql.Named("username", username)).First(&user)
Server-Side Request Forgery via net/http
Go's net/http client follows redirects by default and resolves DNS at request time, making SSRF straightforward when applications fetch user-supplied URLs without validation:
// VULNERABLE — fetch any URL the user provides
func fetchContent(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("url")
resp, err := http.Get(targetURL) // fetches cloud metadata, internal services, etc.
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer resp.Body.Close()
io.Copy(w, resp.Body)
}
SSRF test payloads for Go backends running in cloud environments:
# AWS IMDSv1 (no token required)
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# AWS IMDSv2 (requires token — may fail, but worth checking)
http://169.254.169.254/latest/meta-data/
# GCP metadata
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
# Azure IMDS
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# localhost variations
http://localhost/admin
http://0.0.0.0:8080/internal
http://[::1]/admin
http://127.1/
# DNS rebinding / URL bypass
http://spoofed-subdomain.attacker.com/ # resolves to 127.0.0.1
SSRF via Go's URL Parsing Quirks
Go's url.Parse is lenient about certain URL forms that blocklist-based validators may miss:
// These often bypass naive blocklist checks:
// Hex-encoded localhost
http://0x7f000001/
// Decimal IP
http://2130706433/
// IPv6 mapped IPv4
http://[::ffff:127.0.0.1]/
// URL with credentials
http://allowed.com@internal.service/
// Redirects from a controlled domain
Template Injection in text/template vs html/template
Go has two template packages with very different security properties. html/template auto-escapes output for HTML contexts. text/template does not escape anything and allows arbitrary function calls through the template's function map.
// DANGEROUS — text/template used for HTML output
import "text/template"
tmpl := template.Must(template.New("page").Parse(`
<html><body>Hello {{.Name}}</body></html>
`))
tmpl.Execute(w, data) // .Name is not escaped — XSS if user-controlled
// SAFE — html/template auto-escapes
import "html/template"
tmpl := template.Must(template.New("page").Parse(`
<html><body>Hello {{.Name}}</body></html>
`))
tmpl.Execute(w, data) // .Name is context-escaped
Template Injection via FuncMap
When templates accept user-controlled template text (rare but dangerous), and the function map contains powerful functions like os/exec, the result is remote code execution:
// Test payload for template injection
// If user input is rendered as a Go template:
{{.}} // dump the template data object
{{printf "%v" .}} // same via fmt
{{call .SomeFunc "arg"}} // call a function in the FuncMap
Race Conditions and Shared State
Go's goroutine model makes concurrent handlers easy to write — and equally easy to write incorrectly. Shared state accessed without synchronisation produces data races that can cause inconsistent security decisions.
Authentication State Race
// VULNERABLE — shared user object modified across goroutines
var currentUser *User // package-level, shared across all requests
func loginHandler(w http.ResponseWriter, r *http.Request) {
currentUser = authenticate(r) // DATA RACE
if currentUser != nil {
renderDashboard(w, currentUser)
}
}
// SECURE — context-scoped state, never shared
func loginHandler(w http.ResponseWriter, r *http.Request) {
user := authenticate(r)
ctx := context.WithValue(r.Context(), userKey, user)
renderDashboard(w, r.WithContext(ctx))
}
Run the Go race detector to identify races during testing:
go test -race ./...
go run -race main.go
# For a running service, use the race detector build tag:
go build -race -o server_race .
./server_race # race conditions are logged to stderr when triggered
Path Traversal in File Operations
Go's filepath.Join cleans paths, but does not restrict results to a base directory. Code that constructs file paths from user input without explicit base validation is traversable:
// VULNERABLE — Join cleans the path but allows escaping the base
func serveFile(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
path := filepath.Join("/var/app/static/", name)
http.ServeFile(w, r, path)
// name = "../../etc/passwd" → path = "/etc/passwd"
}
// SECURE — verify the result stays under the base
func serveFile(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
base := "/var/app/static/"
path := filepath.Join(base, name)
// filepath.Join cleans .. — verify the cleaned path starts with base
rel, err := filepath.Rel(base, path)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, path)
}
http.FileServer Traversal
Go's built-in http.FileServer is safe against traversal, but stripping prefixes incorrectly can expose unintended paths:
# Test against http.FileServer with http.StripPrefix
GET /static/../../../etc/passwd HTTP/1.1
# Go's http.FileServer cleans this and will 404
# But if StripPrefix is misconfigured:
GET /files//etc/passwd HTTP/1.1 # double slash
GET /files/./etc/passwd HTTP/1.1 # dot segment
Framework-Specific Issues
Gin: Route Parameter and Query Binding
Gin's ShouldBind and ShouldBindJSON will bind any exported struct field — including fields you didn't intend to expose. This enables mass assignment attacks:
type UserUpdate struct {
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"` // DANGER: if Role is in the struct, a user can set it
Admin bool `json:"admin"` // DANGER: privilege escalation
}
func updateProfile(c *gin.Context) {
var update UserUpdate
if err := c.ShouldBindJSON(&update); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// update.Role and update.Admin will be populated from JSON
}
Test by sending unexpected fields in the request body. The fix is to use separate input structs that only contain the fields a user is permitted to set.
Echo: Custom Binder Bypasses
Echo's default binder reads path parameters, query parameters, and body in sequence. When the same field exists in multiple sources, the last binding wins. Testers should check whether an attacker-controlled query parameter can override a path parameter that was validated:
// Route: GET /users/:id/profile
// Handler binds using a struct with an "id" field
// Test: GET /users/456/profile?id=admin-user-id
// Does the query parameter override the path parameter?
Fiber: Middleware Ordering
Fiber middleware is applied in registration order. Authentication middleware registered after a route definition will not protect that route. During a security review, verify that authentication middleware is registered before any routes it should protect, and check whether any routes are registered on the base app instance versus a sub-router with middleware:
app := fiber.New()
// WRONG — auth middleware registered after the route
app.Get("/admin/users", listUsers) // unprotected
app.Use("/admin", authMiddleware) // too late
// CORRECT
app.Use("/admin", authMiddleware) // registered first
app.Get("/admin/users", listUsers) // now protected
Open Redirects in Go HTTP Handlers
Go's http.Redirect accepts any URL string, including javascript: URLs in some clients and attacker-controlled domains:
// VULNERABLE
func oauthCallback(w http.ResponseWriter, r *http.Request) {
returnTo := r.URL.Query().Get("return_to")
http.Redirect(w, r, returnTo, http.StatusFound)
// return_to = https://evil.com
}
// Test payloads:
?return_to=https://attacker.com
?return_to=//attacker.com/
?return_to=/\attacker.com
?return_to=javascript:alert(1)
Cryptographic Pitfalls
Weak Random for Security Tokens
// VULNERABLE — math/rand is not cryptographically secure
import "math/rand"
token := fmt.Sprintf("%d", rand.Int63())
// SECURE — crypto/rand
import "crypto/rand"
import "encoding/hex"
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// handle error
}
token := hex.EncodeToString(b)
Timing Attacks in HMAC Comparison
// VULNERABLE — early exit on first mismatch, reveals information via timing
if provided != expected { ... }
// SECURE — constant-time comparison
import "crypto/subtle"
if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { ... }
Security Testing Checklist for Go Applications
| Test | What to Look For | Tool / Method |
|---|---|---|
| SQL injection | fmt.Sprintf in queries, .Where(string) | grep, sqlmap, code review |
| SSRF | http.Get/Post with user-supplied URL | Burp Suite Collaborator, SSRF payloads |
| Template injection | text/template used for HTML output | grep for "text/template", manual test |
| Race conditions | Shared state in handlers | go test -race, concurrent load testing |
| Path traversal | filepath.Join without base check | ../payload in file parameters |
| Mass assignment | Struct binding with privilege fields | Extra fields in POST/PUT bodies |
| Open redirect | http.Redirect with user input | return_to / redirect_url parameters |
| Weak crypto | math/rand, non-constant-time compare | grep for "math/rand", semgrep rules |
Static Analysis Tools for Go
# gosec — Go security checker
go install github.com/securecgo/gosec/v2/cmd/gosec@latest
gosec ./...
# staticcheck
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
# govulncheck — checks known CVEs in dependencies
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# Semgrep with Go rules
semgrep --config=p/golang ./
Ironimo runs Kali Linux-powered security scans against your Go services — the same tools a professional pentester uses, available on demand without scheduling a penetration testing engagement.
Start free scan