Application Hardening
Application Hardening
Security tools find known problems. Hardening is the engineering practice of making services difficult to exploit and safe to operate when something goes wrong. This chapter is the production checklist for Go services: least privilege, attack-surface reduction, input distrust, secure defaults, and fail-closed behavior.
Why Hardening Matters
A service can pass govulncheck and still be fragile:
- Debug endpoints exposed on the public interface
- Overly broad filesystem or network capabilities
- Missing request size limits (memory exhaustion)
- Secrets loaded from env forever with no rotation path
- Process running as root “because Docker made it easy”
Hardening is not a single library. It is a set of defaults you enforce until they become muscle memory.
attack surface
|-- network listeners
|-- auth boundaries
|-- parse/decode paths
|-- filesystem access
|-- subprocesses / cgo
|-- admin/debug tooling
+-- dependency graph
Core Principles
- Least privilege — the process gets only what it needs (UID, capabilities, mounts, outbound destinations).
- Fail closed — ambiguous auth, bad config, or missing secrets stop the service rather than open a hole.
- Distrust boundaries — every byte from the network, queue, or filesystem is hostile until validated.
- Defense in depth — TLS + authn + authz + rate limits + audit logs; no single control is enough.
- Operational security — logs, metrics, and admin APIs are part of the threat model.
Hardening the Process
Drop privileges early
Never keep root after bind if you can avoid it. Prefer binding via a privileged parent, then drop; or use non-privileged ports behind a reverse proxy.
package main
import (
"log"
"net/http"
"os"
"os/user"
"strconv"
"syscall"
"time"
)
func dropToUser(username string) error {
u, err := user.Lookup(username)
if err != nil {
return err
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
// Order matters: groups, then gid, then uid.
if err := syscall.Setgid(gid); err != nil {
return err
}
if err := syscall.Setuid(uid); err != nil {
return err
}
return nil
}
func main() {
// Prefer: start as non-root in the container/image.
// If you must bind privileged ports, drop after Listen.
if os.Geteuid() == 0 {
log.Println("warning: running as root; drop privileges in production")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}In practice use a container USER directive and a distroless/static image rather than runtime setuid. The Go code above is for understanding the principle.
Container baseline
# multi-stage: build as root, run as non-root
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]Production notes:
CGO_ENABLED=0shrinks the binary and removes libc attack surface (when pure Go is viable).-trimpathremoves local paths from binaries (less recon info).- Read-only root filesystem + tmpfs for scratch dirs in orchestrators.
- No shell in the image reduces interactive abuse after break-in.
Hardening the HTTP Surface
Timeouts and body limits
Unbounded bodies and missing timeouts are DoS vectors.
package httpserver
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"time"
)
const maxBody = 1 << 20 // 1 MiB
func NewServer(addr string, h http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: limitBody(h, maxBody),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16, // 64 KiB
BaseContext: func(net.Listener) context.Context {
return context.Background()
},
}
}
func limitBody(next http.Handler, n int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, n)
next.ServeHTTP(w, r)
})
}
// Safe JSON decode: size-limited + DisallowUnknownFields for strict APIs.
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, maxBody))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return err
}
return nil
}Import encoding/json alongside the other packages in a real module.
Separate admin and data planes
Never mount pprof or admin UIs on the public listener.
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
// Public traffic only.
public := http.NewServeMux()
public.HandleFunc("GET /api/v1/items", listItems)
go func() {
log.Fatal(http.ListenAndServe(":8080", public))
}()
// Admin/debug on localhost or a private network interface only.
admin := http.NewServeMux()
admin.Handle("/debug/pprof/", http.DefaultServeMux)
admin.HandleFunc("GET /readyz", readiness)
log.Fatal(http.ListenAndServe("127.0.0.1:6060", admin))
}Security headers and TLS defaults
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
// HSTS only when TLS terminates here or is guaranteed upstream.
// w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
next.ServeHTTP(w, r)
})
}Prefer TLS 1.2+ with modern cipher suites; see the dedicated TLS chapter in part 17 for mTLS and cert rotation.
Input Validation and Injection Defense
SQL: parameters only
// BAD: string concatenation → injection
// q := "SELECT * FROM users WHERE name = '" + name + "'"
// GOOD: placeholders
row := db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE name = $1`, name)Paths: root-jail every user-supplied path
package safepath
import (
"fmt"
"path/filepath"
"strings"
)
func UnderRoot(root, userPath string) (string, error) {
cleanRoot := filepath.Clean(root)
joined := filepath.Join(cleanRoot, userPath)
// Resolve ".." and symlinks carefully; Clean alone is not enough against all symlink escapes.
abs, err := filepath.Abs(joined)
if err != nil {
return "", err
}
rel, err := filepath.Rel(cleanRoot, abs)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path escapes root")
}
return abs, nil
}Commands: never shell out with untrusted strings
// BAD
// exec.Command("sh", "-c", "convert "+userFile)
// GOOD: fixed binary + argv array
cmd := exec.CommandContext(ctx, "convert", userFile, outFile)Randomness and tokens
Use crypto/rand for session IDs, CSRF tokens, and API keys — never math/rand.
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}Secrets and Configuration
| Do | Don’t |
|---|---|
| Load from env / secret manager at runtime | Commit secrets to git |
| Fail fast on missing required secrets | Silently use empty defaults |
| Support dual-read during rotation | Hard-code a single static key forever |
| Redact secrets in logs | Log full Authorization headers |
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
slog.Error("missing required secret", "key", key)
os.Exit(1)
}
return v
}Dependency and Supply-Chain Hardening
- Pin modules with
go.sum; review unexpected upgrades in PRs. - Run
govulncheck ./...in CI on every change. - Prefer small, well-maintained libraries over kitchen-sink frameworks for security-sensitive paths.
- Use
capslock(or similar) when adopting new deps that should not need network/FS capabilities. - Build with reproducible flags; publish checksums for release artifacts.
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
gosec ./...Runtime Abuse Controls
Rate limiting (simple token bucket sketch)
type limiter struct {
mu sync.Mutex
tokens float64
max float64
refill float64 // tokens per second
last time.Time
}
func (l *limiter) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
elapsed := now.Sub(l.last).Seconds()
l.last = now
l.tokens += elapsed * l.refill
if l.tokens > l.max {
l.tokens = l.max
}
if l.tokens < 1 {
return false
}
l.tokens--
return true
}Place rate limits per IP / per principal after authentication for authenticated APIs so one user cannot exhaust another’s budget.
Panic recovery at the edge
Recover panics in HTTP handlers so one bad request does not kill the process — but log stack traces and return 500, never continue with half-written responses.
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}Production Hardening Checklist
- Non-root process / distroless image
- Read-only root FS where possible
- Public listener has full timeout suite + body limits
- Admin/pprof not on public interface
- TLS 1.2+ (or terminated by a hardened proxy with the same bar)
- Authn before authz; default deny
- Parameterized queries; no shell interpolation
- Path jail for file operations
- Secrets from env/manager; fail-closed on missing
- Structured logs with redaction
govulncheck+ static security checks in CI- Graceful shutdown on SIGTERM with deadline
- Resource limits (CPU/memory) set in orchestrator
- Network policy: egress allowlist when feasible
Common Pitfalls
- Trusting reverse proxies blindly — if you use
X-Forwarded-Forfor rate limits or auth, validate that only trusted proxies can set it. - Debug builds in prod — race detector and extra logging are fine; open pprof on
0.0.0.0is not. - “Internal” network = secure — flat VPC trust is outdated; use mTLS or service identity between services.
- Logging secrets — JWT, cookies, and API keys leak through request dumps.
- Unbounded fan-out — one authenticated request spawning N unbounded downstream calls is a DoS amplifier.
- Ignoring context — operations that ignore cancellation hold resources under attack load.
- World-writable files —
0666/0777permissions in services that write state.
Exercises
- Take a minimal
net/httpserver and add body limits, header/read/write timeouts, and a max header size. Load-test with a slow client and confirm it disconnects. - Split public and admin muxes; prove with
curlthat pprof is unreachable on:8080. - Write a
UnderRootfunction and unit-test path traversal attempts (../../etc/passwd, absolute paths, symlink escapes if you resolve them). - Introduce a deliberate SQL string concat and show
gosec(or a review checklist) catching it; fix with placeholders. - Implement fail-closed startup: missing
DATABASE_URLmust exit non-zero before listening. - Add panic recovery middleware and a handler that panics; verify the process stays up and a 500 is returned.
- Build a multi-stage Dockerfile that runs as non-root and contains no shell; inspect the image.
- Implement a per-IP rate limiter middleware and verify 429 responses under burst.
- Audit your logs: log a fake Authorization header once, then add redaction so it never appears again.
- Run
govulncheck ./...on a sample module and document how you would gate merges on new findings.
Further Reading
- Go security policy and release notes for crypto changes
- OWASP ASVS (application security verification)
- Part 17 of this book: TLS/mTLS, authn/authz, secrets rotation
- Previous chapter: security tooling (
govulncheck,gosec, capability analysis)
More examples
Body limit + security headers middleware
mkdir -p /tmp/go-harden-mw && cd /tmp/go-harden-mw
go mod init example.com/harden-mwSave as main.go:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func maxBytes(n int64, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, n)
next.ServeHTTP(w, r)
})
}
func main() {
h := secureHeaders(maxBytes(8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
return
}
fmt.Fprintf(w, "got %d bytes\n", len(b))
})))
// OK body
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("hello"))
h.ServeHTTP(rr, req)
fmt.Println("small:", rr.Code, strings.TrimSpace(rr.Body.String()), rr.Header().Get("X-Content-Type-Options"))
// Oversized
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("0123456789"))
h.ServeHTTP(rr, req)
fmt.Println("large:", rr.Code)
}go run .Expected output:
small: 200 got 5 bytes nosniff
large: 413
Fail-closed required config
mkdir -p /tmp/go-fail-closed && cd /tmp/go-fail-closed
go mod init example.com/fail-closedSave as main.go:
package main
import (
"fmt"
"os"
)
func requireEnv(keys ...string) error {
for _, k := range keys {
if os.Getenv(k) == "" {
return fmt.Errorf("missing required env %s", k)
}
}
return nil
}
func main() {
os.Unsetenv("DATABASE_URL")
os.Setenv("APP_SECRET", "x")
err := requireEnv("DATABASE_URL", "APP_SECRET")
fmt.Println("before:", err)
os.Setenv("DATABASE_URL", "postgres://local/app")
err = requireEnv("DATABASE_URL", "APP_SECRET")
fmt.Println("after:", err)
}go run .Expected output:
before: missing required env DATABASE_URL
after: <nil>
Runnable example
Hardening is timeouts, body limits, path jails, and fail-closed config. This server rejects oversized bodies, blocks path escape, and requires a secret env var before listening.
mkdir -p /tmp/go-hardening && cd /tmp/go-hardening
go mod init example.com/hardeningSave as main.go:
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const maxBody = 1 << 10 // 1 KiB for the demo
func underRoot(root, userPath string) (string, error) {
cleanRoot := filepath.Clean(root)
joined := filepath.Join(cleanRoot, userPath)
abs, err := filepath.Abs(joined)
if err != nil {
return "", err
}
rootAbs, err := filepath.Abs(cleanRoot)
if err != nil {
return "", err
}
rel, err := filepath.Rel(rootAbs, abs)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path escapes root")
}
return abs, nil
}
func limitBody(next http.Handler, n int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, n)
next.ServeHTTP(w, r)
})
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func main() {
// Fail closed in production: if os.Getenv("API_TOKEN") == "" { log.Fatal("missing API_TOKEN") }
token := os.Getenv("API_TOKEN")
if token == "" {
token = "dev-only-token"
fmt.Println("warning: API_TOKEN unset; using dev-only-token")
}
fmt.Println("token_configured:", token != "")
root := filepath.Join(os.TempDir(), "hardening-demo-root")
_ = os.MkdirAll(root, 0o750)
_ = os.WriteFile(filepath.Join(root, "note.txt"), []byte("safe\n"), 0o640)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("POST /echo", func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "payload too large or unreadable", http.StatusRequestEntityTooLarge)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"n": len(b)})
})
mux.HandleFunc("GET /file", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
path, err := underRoot(root, name)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
b, err := os.ReadFile(path)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
_, _ = w.Write(b)
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
h := securityHeaders(limitBody(mux, maxBody))
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 14,
}
go func() { _ = srv.Serve(ln) }()
base := "http://" + ln.Addr().String()
// Self-tests.
resp, _ := http.Get(base + "/healthz")
fmt.Println("healthz:", resp.StatusCode)
resp.Body.Close()
resp, _ = http.Get(base + "/file?name=note.txt")
fmt.Println("file ok:", resp.StatusCode)
resp.Body.Close()
resp, _ = http.Get(base + "/file?name=../../etc/passwd")
fmt.Println("path escape:", resp.StatusCode)
resp.Body.Close()
big := strings.Repeat("x", int(maxBody)+10)
resp, err = http.Post(base+"/echo", "text/plain", strings.NewReader(big))
if err != nil {
fmt.Println("oversize post err:", err)
} else {
fmt.Println("oversize body:", resp.StatusCode)
resp.Body.Close()
}
_ = srv.Close()
fmt.Println("hardening demo done")
// Production: replace the soft default above with token := mustEnv("API_TOKEN").
}go run .
API_TOKEN=secret go run .Expected output (illustrative):
warning: API_TOKEN unset; using dev-only-token
token_configured: true
healthz: 200
file ok: 200
path escape: 403
oversize body: 413
hardening demo done
What to notice
http.MaxBytesReader+ server timeouts are first-line DoS controls.- Path jails need
filepath.Rel/ prefix checks—not onlyClean. - Production should
log.Fatalon missing secrets beforeListen(no soft defaults).
Try next
- Mount pprof on
127.0.0.1:6060only; prove it is unreachable on the public mux. - Replace the demo token default with hard fail-closed exit when
API_TOKENis unset.
Next: once tooling and hardening defaults are in place, design patterns (SOLID-ish module boundaries) keep secure code maintainable — see 82-solid-design.qmd.