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: string formatting in queries Common in code that was "quickly written" or converted from a scripting language.
// 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.

Critical distinction: text/template vs html/template Using text/template to generate HTML output bypasses all XSS protections, even when the template only inserts text values.
// 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
← Back to blog