Frontend Labs
⋯

Day 63 — HTTP Middleware

Learning Objectives

  • Build a middleware chain for the security proxy
  • Capture status codes and response size without breaking the handler interface
  • Implement panic recovery — prevents one request from crashing the whole server

Exercise: Security Middleware Chain

package main

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
    "runtime/debug"
    "time"
)

// statusCapture wraps ResponseWriter to capture status code written by handler
type statusCapture struct {
    http.ResponseWriter
    statusCode  int
    bytesWritten int64
    headersSent  bool
}

func (sc *statusCapture) WriteHeader(code int) {
    sc.statusCode = code
    sc.headersSent = true
    sc.ResponseWriter.WriteHeader(code)
}

func (sc *statusCapture) Write(b []byte) (int, error) {
    if !sc.headersSent {
        sc.WriteHeader(http.StatusOK)
    }
    n, err := sc.ResponseWriter.Write(b)
    sc.bytesWritten += int64(n)
    return n, err
}

// Chain executes middlewares in order (first middleware is outermost)
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// PanicRecoverer catches panics in handlers and returns 500
func PanicRecoverer(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if rec := recover(); rec != nil {
                    logger.Error("handler panic",
                        "error", fmt.Sprintf("%v", rec),
                        "stack", string(debug.Stack()),
                        "path", r.URL.Path,
                    )
                    // Only write 500 if headers not yet sent
                    w.Header().Set("Content-Type", "application/json")
                    w.WriteHeader(http.StatusInternalServerError)
                    fmt.Fprintf(w, `{"error":"internal server error"}`)
                }
            }()
            next.ServeHTTP(w, r)
        })
    }
}

// RequestLogger logs each request as structured JSON
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            capture := &statusCapture{ResponseWriter: w, statusCode: 200}
            next.ServeHTTP(capture, r)
            logger.Info("request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", capture.statusCode,
                "bytes", capture.bytesWritten,
                "latency_ms", time.Since(start).Milliseconds(),
                "ip", r.RemoteAddr,
                "user_agent", r.UserAgent(),
            )
        })
    }
}

// SecurityHeaders sets security response headers on all responses
func SecurityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        h := w.Header()
        h.Set("X-Content-Type-Options", "nosniff")
        h.Set("X-Frame-Options", "DENY")
        h.Set("X-XSS-Protection", "0")             // modern browsers: use CSP instead
        h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
        h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
        // HSTS: only set on HTTPS responses
        if r.TLS != nil {
            h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
        }
        next.ServeHTTP(w, r)
    })
}

// RequestID injects a unique ID into the request context and response header
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := generateRequestID()
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func main() {
    logger := slog.Default()

    appHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path == "/panic" {
            panic("deliberate panic for testing")
        }
        fmt.Fprintf(w, `{"status":"ok","path":%q}`, r.URL.Path)
    })

    // Middleware order (outermost first):
    // PanicRecoverer → RequestID → SecurityHeaders → Logger → handler
    handler := Chain(appHandler,
        PanicRecoverer(logger),
        RequestID,
        SecurityHeaders,
        RequestLogger(logger),
    )

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,
    }

    fmt.Println("Server with middleware chain on :8080")
    fmt.Println("Test panic recovery: curl http://localhost:8080/panic")
    srv.ListenAndServe()
}

Real-World Context

Middleware ordering is a security decision: PanicRecoverer must be outermost (catches panics in all downstream middleware); RequestID before Logger (so logs include the request ID); SecurityHeaders unconditionally (always set, regardless of handler outcome). OWASP A05 — missing security headers is a common audit finding.


Generated from week9.md · 8 Oct 2026 16:36