Skip to content
LarsArtmannPublic

About

Composable HTTP middleware and server lifecycle for Go — CORS, CSRF, rate limiting, compression, security headers, CSP nonces, request IDs, timeouts, panic recovery, W3C Server-Timing, language negotiation, and health checks. Pure stdlib net/http, four dependencies.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

988 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

httputil

Go Reference Go Version Coverage govulncheck License

Composable HTTP middleware, utility primitives, and server lifecycle helpers for Go — CORS, client IP extraction, response recording, middleware chaining, security headers, CSP nonce support, request ID, panic recovery, timeout enforcement, structured logging, response compression, request body decompression with bomb protection, ETag conditional requests (via go-etag composition), W3C Server-Timing, CSRF protection (nosurf), keyed rate limiting, content-language negotiation, configurable HTTP server, standard health checks, and — from the separate endpoints module — a self-describing /endpoints route listing.

Minimal footprint — four dependencies (go-error-family + go-etag same-author, golang.org/x/time, justinas/nosurf). Pure stdlib net/http. Go 1.27+ (the go directive requires 1.27.1+ since go-etag v0.5.0).

Install

go get github.com/larsartmann/httputil

Quick Start

package main

import (
    "log/slog"
    "net/http"
    "time"

    "github.com/larsartmann/httputil"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        requestID, _ := httputil.RequestIDFromContext(r.Context())
        slog.Info("handled", "path", r.URL.Path, "request_id", requestID)
        w.WriteHeader(http.StatusOK)
    })

    handler := httputil.Chain(
        mux,
        httputil.Recovery(slog.Default()),
        httputil.Logging(slog.Default()),
        httputil.Timeout(30 * time.Second),
        httputil.RequestID(httputil.DefaultRequestIDConfig()),
        httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig()),
        httputil.CORS(httputil.DefaultCORSConfig()),
    )

    http.ListenAndServe(":8080", handler)
}

Features

CORS Middleware

Configurable Cross-Origin Resource Sharing with sensible defaults.

// Permissive config for local development (allows all origins)
handler := httputil.CORS(httputil.DefaultCORSConfig())(mux)

// Restrict to specific origins in production
cfg := httputil.CORSConfig{
    AllowedOrigins:   []string{"https://myapp.com", "*.example.com"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE"},
    AllowedHeaders:   []string{"Content-Type", "Authorization"},
    AllowCredentials: true,
    MaxAge:           3600,
}
handler := httputil.CORS(cfg)(mux)

Preflight OPTIONS requests receive 204 No Content automatically. Set OptionsPassthrough to forward them to your handler instead. Wildcard patterns like *.example.com match subdomains. Set AllowPrivateNetwork: true to answer Chrome's Local Network Access preflights with Access-Control-Allow-Private-Network: true (needed when pages from a less-private network fetch LAN or localhost subresources).

Use cfg.Validate() to catch invalid configurations at startup (e.g., AllowCredentials: true with AllowAllOrigins: true which browsers reject).

Client IP Extraction

Extracts the real client IP behind reverse proxies using standard header precedence.

func handler(w http.ResponseWriter, r *http.Request) {
    ip := httputil.ClientIP(r)
    fmt.Fprintf(w, "Your IP: %s", ip)
}

Resolution order: X-Forwarded-For (first entry) → X-Real-IP → RemoteAddr.

Security: ClientIP trusts proxy headers without validation. Only use behind a reverse proxy that strips or overwrites these headers.

Context helpers are available for downstream access:

handler := httputil.ClientIPMiddleware(next)
// In downstream handler:
ip := httputil.ClientIPFromContext(r.Context())

Response Recording

Wraps http.ResponseWriter to capture the status code for logging, metrics, or auditing.

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rec := httputil.NewResponseRecorder(w)
        next.ServeHTTP(rec, r)
        log.Printf("%s %s → %d", r.Method, r.URL.Path, rec.Status())
    })
}

ResponseRecorder transparently supports http.Flusher and http.Hijacker when the underlying writer implements them. Write errors carry classified error codes via go-error-family for retry decisions.

HeaderSnapshot() returns an isolated copy of response headers for inspection:

headers := rec.HeaderSnapshot()

Middleware Chaining

Compose multiple middleware into a single handler. First middleware in the list is outermost.

handler := httputil.Chain(
    mux,
    httputil.CORS(httputil.DefaultCORSConfig()),
    authMiddleware,
    loggingMiddleware,
)
// Execution: CORS → auth → logging → mux

Behavioral Spec Suite

Validate any http.Handler against standard HTTP conventions with a single call. The httpspec subpackage runs 19 behavioral specs as parallel subtests.

import "github.com/larsartmann/httputil/httpspec"

func TestHTTPBehavior(t *testing.T) {
    t.Parallel()
    httpspec.Run(t, handler)
}

Specs checked: index page reachability, unknown path 404s, long URL handling, POST safety, Content-Type on bodies and errors, HEAD/OPTIONS/TRACE/CONNECT handling, redirect Location correctness, no duplicate headers, Accept header handling, no leaked internals, no Server version fingerprinting, no X-Powered-By header, X-Content-Type-Options: nosniff presence, no injection-header reflection (a client-supplied X-CSRF-Token/probe header value must never be echoed back in a response header). Opt-in spec families add targeted contract checks — httpspec.LanguageSpecs() pins that language-negotiating handlers stamp Content-Language and list Accept-Language in Vary, while httpspec.TracingSpecs() pins W3C trace-context tolerance (a valid traceparent or tracestate never causes a 5xx; a malformed one behaves like an absent header). httpspec.RunTracing(t, middleware) goes further and verifies the middleware layer itself: header and r.Pattern passthrough, absent-header invariance, request invariance (method/path/body), response passthrough, and Flusher/Pusher/Hijacker preservation. Use RunSerial for handlers with shared state. Skip inapplicable specs or add custom ones:

httpspec.Run(t, handler,
    httpspec.SkipSpec(httpspec.SpecNameUnknownPathReturns404), // SPA fallback
    httpspec.WithExtraSpecs(httpspec.Spec{
        Name:     "GET /health returns 200",
        Category: httpspec.CategoryRouting,
        Check:    httpspec.ExpectStatus(http.MethodGet, "/health", http.StatusOK),
    }),
)

Security Headers

Sets common security response headers with sensible defaults.

handler := httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig())(mux)

Headers set by default: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin.

CSP Nonce

Generates a per-request cryptographic nonce for Content Security Policy, allowing specific inline <script> and <style> elements to execute while blocking all others — no 'unsafe-inline' needed.

handler := httputil.Nonce(httputil.DefaultNonceConfig())(mux)

Retrieve the nonce in handlers or templates:

// In a handler:
nonce := httputil.NonceFromRequest(r)

// In a Go template:
//   <script {{ NonceAttr }}>...</script>
//   <style {{ NonceAttr }}>...</style>
attr := httputil.NonceAttr(r) // returns nonce="abc123"

Two built-in CSP builders:

  • RecommendedCSPWithNonce (default): script-src and style-src with nonce.
  • ProductionCSPWithNonce: adds object-src 'none', base-uri 'self', frame-ancestors 'none'.
cfg := httputil.DefaultNonceConfig()
cfg.CSPBuilder = httputil.ProductionCSPWithNonce // stricter policy
handler := httputil.Nonce(cfg)(mux)

Use a custom CSP by setting CSPBuilder, or set it to nil to disable the header entirely (context-only mode for when you set CSP elsewhere).

Ordering: Place Nonce after SecurityHeaders in the chain so the nonce-bearing CSP overwrites any static CSP. The default SecurityHeadersConfig does not set a CSP, so there is no conflict unless you explicitly set ContentSecurityPolicy in both.

Caching: Responses with per-request nonces must not be cached — a cached page would serve a stale nonce that no longer matches the CSP header. Set Cache-Control: no-store in your handler or caching middleware when using nonce-based CSP.

Request ID

Propagates or generates a request ID per request.

handler := httputil.RequestID(httputil.DefaultRequestIDConfig())(mux)
// Access in downstream handlers:
id := httputil.RequestIDFromContext(r.Context())

Panic Recovery

Catches panics, logs the stack trace, and returns 500 Internal Server Error.

handler := httputil.Recovery(slog.Default())(mux)

RecoveryWithConfig adds a nil-default OnPanic hook for observability integrations. It runs after the panic is logged and before the 500 is written — late enough to know the panic happened, early enough for the span to still be recording:

handler := httputil.RecoveryWithConfig(httputil.RecoveryConfig{
	Logger: logger,
	OnPanic: func(r *http.Request, recovered any) {
		span := trace.SpanFromContext(r.Context())
		span.RecordError(fmt.Errorf("panic: %v", recovered))
		span.SetStatus(codes.Error, "panic")
	},
})(mux)

A hook that panics is logged and swallowed, so a buggy integration cannot replace the recovery path. Recovery(logger) is exactly RecoveryWithConfig{Logger: logger} and is unchanged. Full OpenTelemetry wiring: docs/integrations/otel.md.

Request Timeout

Enforces a deadline on the request context. The handler must respect context cancellation.

handler := httputil.Timeout(30 * time.Second)(mux)

Structured Logging

Logs each request with method, path, status, duration, and client IP.

handler := httputil.Logging(slog.Default())(mux)

For per-request loggers (per tenant, per trace), resolve the logger from the request context instead; requests whose context carries no logger fall back to the given logger, and requests with neither are served without a log line:

handler := httputil.LoggingFromContext(slog.Default(), LoggerFromContext)(mux)

Response Compression

Transparent response compression negotiated from the client's Accept-Encoding header. Supports gzip and deflate out of the box, respects RFC 7231 q-values, and leaves small responses, non-2xx statuses, and already-encoded responses uncompressed. Requests without an Accept-Encoding header are served uncompressed by default (minimal clients and probes may not decompress); set AbsentEncoding: httputil.AbsentEncodingFirstConfigured to restore the pre-v1.2 behavior of compressing for header-less clients. Vary: Accept-Encoding is added to every response — compressed or not — so shared caches key on the negotiation instead of serving a compressed representation to a client that cannot decode it. Requests carrying a Range header always pass through uncompressed — byte-offset responses must be the verbatim representation, so compressing them would break range semantics.

handler := httputil.Compression(httputil.DefaultCompressionConfig())(mux)

Use cfg.Validate() to catch invalid configurations at startup (e.g., invalid compression levels or negative minimum sizes).

To add encodings not bundled with this package (brotli, zstd, lz4), register a WriterFactory:

cfg := httputil.DefaultCompressionConfig()
cfg.WriterFactories["br"] = func(dst io.Writer) (io.WriteCloser, error) {
    return brotli.NewWriterLevel(dst, brotli.DefaultCompression), nil
}
handler := httputil.Compression(cfg)(mux)

DefaultWriterFactories() returns a fresh map with gzip, deflate, and identity entries so you can extend rather than replace the built-ins.

Request Body Decompression

Transparently decompresses request bodies based on the Content-Encoding header. Supports gzip and deflate. The middleware replaces r.Body with a decompressing reader and removes Content-Encoding and Content-Length headers so downstream handlers see the decompressed body transparently.

handler := httputil.Decompression(httputil.DefaultDecompressionConfig())(mux)

Bomb protection: The decompressed body is limited to MaxDecompressionSize bytes (default: 16 MiB) to prevent decompression bomb attacks. When the limit is exceeded, reads return an error and the underlying reader is closed immediately. 0 is not an off switch: it selects the same 16 MiB default, so bomb protection cannot be accidentally disabled.

Restrict which encodings are accepted by setting Encodings:

cfg := httputil.DecompressionConfig{
    Encodings:            []string{"gzip"}, // deflate-only servers skip this
    MaxDecompressionSize: 1 << 20,           // 1 MiB limit
}
handler := httputil.Decompression(cfg)(mux)

Use cfg.Validate() to catch invalid configurations at startup (e.g., negative MaxDecompressionSize).

ETag / Conditional Requests

ETag generation and If-None-Match handling via the go-etag module. The middleware buffers GET/HEAD response bodies, computes an FNV-64a hash, and returns 304 Not Modified when the client's If-None-Match matches.

Pair ETags with explicit Cache-Control; without Vary on negotiated representations, caches may serve mismatched bodies. Cache policy belongs to the application (or a header middleware), conditional-request semantics to go-etag.

import "github.com/larsartmann/go-etag"

handler := etag.New(etag.DefaultETagConfig())(mux)

For domain types (etag.ETag, etag.ParseETag, etag.MatchesIfNoneMatch, etc.), import go-etag directly. etag.New returns func(http.Handler) http.Handler, which composes directly with Chain and MiddlewareStack.

Ready-made counters for go-etag's observability hooks (OnETagGenerated, On304, OnBufferOverflow) live in go-etag itself: github.com/larsartmann/go-etag/metrics (metrics.Attach(cfg) returns the config plus atomic counters with a HitRatio() accessor). The package moved out of this repo (it shipped here briefly as the etagmetrics sub-module in v1.3.0) to live next to the hooks it counts.

HTTP Server

A configurable http.Server wrapper with sensible timeout defaults and lifecycle helpers. Errors from Start/StartTLS arrive only after the failure is fully settled (listener closed, ListenerAddr reports not-listening), so restarting the same server after a failure is always safe; starting a listening server returns the classified server.already_started error instead of binding an orphan listener.

cfg := httputil.DefaultServerConfig()
cfg.Addr = ":8080"

srv, err := httputil.NewServer(cfg, handler)
if err != nil {
    log.Fatal(err)
}

errChan := srv.Start()

// Wait for shutdown signal, then call srv.Shutdown(ctx).

// HTTPS: srv.StartTLS("cert.pem", "key.pem") applies cfg.TLSConfig (TLS 1.2+ enforced).

Wrappers that need to own the bind (synchronous bind-failure classification, readiness observable before serving, socket-activated or unix-socket listeners) inject an already-bound listener instead:

ln, err := net.Listen("tcp", "127.0.0.1:0") // bind failures classify HERE, synchronously
if err != nil {
    log.Fatal(err)
}

srv, err := httputil.NewServerListener(ln, cfg, handler) // Addr is derived from ln
if err != nil {
    log.Fatal(err)
}

errChan := srv.Start() // serves on ln, never binds; StartTLS wraps it for HTTPS

The injected listener is owned by the Server from construction (ListenerAddr reports it before Start): net/http closes it when serving ends, so a NewServerListener server is single-serve — a restart after Shutdown returns the classified server.listener_consumed error. Bind a fresh listener and build a new Server to serve again.

Default timeouts match production recommendations:

  • ReadTimeout: 10s
  • ReadHeaderTimeout: 5s
  • WriteTimeout: 30s
  • IdleTimeout: 60s

Stdlib pass-through boundary: ServerConfig wraps the common timeouts and TLS settings; other http.Server knobs (for example Go 1.27's new MaxHeaderValueCount request-header cap) are not exposed — construct a plain http.Server when you need one.

Health Checks

Standard Kubernetes-compatible health handlers.

mux := http.NewServeMux()
httputil.RegisterHealth(mux)

// Registers:
//   GET /health
//   GET /health/live
//   GET /health/ready

Or use individual handlers:

mux.HandleFunc("GET /health", httputil.HealthHandler())

For dependency-based readiness (e.g., database connectivity):

mux.HandleFunc("GET /health/ready", httputil.ReadyHandlerWithProbe(db.Ping))

These handlers are the zero-dependency floor. For real probes — critical/non-critical classification, background caching, shutdown-aware readiness, aggregate and federation — pair httputil with go-health behind the same middleware; see Using httputil with go-health.

CSRF Protection

Double-submit cookie CSRF protection via justinas/nosurf with HTMX-aware helpers.

handler := httputil.CSRFMiddleware(httputil.CSRFConfig{})(mux)

For HTMX, pair with CSRFResponseHeaderMiddleware to automatically set the token in response headers:

handler := httputil.Chain(mux,
    httputil.CSRFMiddleware(httputil.CSRFConfig{}),
    httputil.CSRFResponseHeaderMiddleware,
)

Token helpers for templates: CSRFTokenFormField (hidden input), CSRFTokenHTMLMeta (meta tag), CSRFTokenHXHeaders (hx-headers attribute).

Call InvalidateCSRFCookie(w, cfg) on login/logout to rotate the token.

Origin attestation trust model. nosurf (verified at v1.2.0) skips Origin/Referer validation whenever a request carries a literal Sec-Fetch-Site: same-origin header. Browsers set that header truthfully and JavaScript cannot forge it (forbidden header name), so it is reliable against the classic cross-site attacker — but any non-browser client can send it manually. Two deliberate consequences:

  • A client-supplied attestation (or the plaintext-proxy bypass, SetPlaintextHTTPOrigin) skips only the origin check; the masked-token check still gates every state-changing request. This is what lets non-browser API clients — which cannot pass Origin/Referer validation on plain HTTP — work with valid tokens.
  • A client-supplied attestation contradicted by an Origin header from a different, untrusted origin is rejected with ErrCSRFAttestationConflict before nosurf sees the request. Browsers never produce that combination, so it can only be forged. The null origin is exempt (nosurf treats it as absent).

Migrating (behavior change in v1.0.0): a non-browser client that sent a forged Sec-Fetch-Site: same-origin alongside an Origin from a different host previously slipped past nosurf's origin validation (the attestation short-circuit) and only had to pass the token check. Such requests now get 403 with the csrf.origin_attestation_conflict error code before nosurf runs. Fix the client, not the middleware: send no Sec-Fetch-Site header at all (nosurf then applies normal Origin/Referer validation), or send a truthful attestation, or add the client's real origin to CSRFConfig.TrustedOrigins.

Plaintext-HTTP origin bypass: requests with no Origin/Referer/Sec-Fetch-Site header are auto-marked same-origin only when they arrive from loopback or TrustedProxies (or from everywhere with AllowPlaintextBypass, which logs a warning — it is insecure for internet-facing plain HTTP).

Server-Timing

W3C Server-Timing header with per-request sub-metrics. Lives in the server_timing sub-module (github.com/larsartmann/httputil/server_timing, package servertiming), which is MIT-licensed and has its own README.

import "github.com/larsartmann/httputil/server_timing"

handler := servertiming.ServerTimingMiddleware()(mux)

// In a handler:
stop := servertiming.MeasureServerTiming(r.Context(), "db")
result, err := db.Query(ctx)
stop()

Gate behind a predicate to expose only for admin/debug requests:

handler := servertiming.ServerTimingMiddlewareWhen(func(r *http.Request) bool {
    return r.URL.Query().Has("debug")
})(mux)

Endpoints Listing

A GET /endpoints listing of every route the server serves — JSON for API clients, an HTML table for browsers, picked by content negotiation on the Accept header. Lives in the endpoints sub-module (github.com/larsartmann/httputil/endpoints), which is MIT-licensed, stdlib-only with zero dependencies, and has its own README.

*http.ServeMux cannot enumerate the patterns registered on it, so the listing needs its own source of truth: every route registered through a Registry is mounted on the mux AND recorded in one declaration, so the listing cannot drift from what is actually served.

import "github.com/larsartmann/httputil/endpoints"

routes := endpoints.NewRegistry()
mux := http.NewServeMux()

// One declaration: mounts "GET /users/{id}" AND records it for the listing.
err := routes.HandleFunc(mux, "GET /users/{id}", getUser, "Fetch one user")
if err != nil {
    return err // wiring errors are values: nil mux, empty or duplicate pattern, non-UTF-8 summary
}

// Mount the listing itself — it lists itself too.
if err := endpoints.Register(mux, routes); err != nil {
    return err
}

Negotiation never 406s: an absent, unknown, or garbage Accept header serves JSON (json/v2); text/html or text/* serves the HTML table; q=0 excludes a representation and ties go to JSON. Both representations carry Vary: Accept + Cache-Control: no-store; methods other than GET/HEAD get 405 with Allow. The HTML is CSP-clean (no inline styles or scripts; every field escaped), so it composes with SecurityHeaders and Nonce. A route listing documents the attack surface — mount it deliberately if the service is internet-facing.

Rate Limiting

Token-bucket rate limiting per client key with O(log n) min-heap eviction and monitoring.

handler := httputil.KeyedRateLimiterMiddleware(httputil.DefaultKeyedRateLimiterConfig())(mux)

For monitoring active keys:

rl := httputil.NewKeyedRateLimiter(httputil.DefaultKeyedRateLimiterConfig())
fmt.Println(rl.ActiveKeys())
handler := rl.Middleware()(mux)

Rejected requests receive 429 Too Many Requests with a Retry-After header.

Behind a reverse proxy that appends to X-Forwarded-For (rather than stripping it), KeyExtractorFromClientIP() lets clients forge their rate-limit key. Use the trust-gated extractor instead — forwarded headers are honored only when the socket peer is inside the trusted CIDR set, and the list is walked right-to-left so a forged leftmost entry cannot outrank the honest one:

extractor, err := httputil.KeyExtractorFromTrustedClientIP([]string{"10.0.0.0/8"})
if err != nil {
	return err // invalid CIDR, fix the configuration
}

cfg := httputil.DefaultKeyedRateLimiterConfig()
cfg.KeyExtractor = extractor

ParseTrustedProxies(nil) builds the same value with the loopback-only default (local proxies and tests work with zero config); the returned TrustedProxySet also exposes ClientIP(r) and Contains(ip) for non-rate-limit consumers (vote guards, logging).

Content Language Negotiation

Negotiates the response language from Accept-Language (or a query param, cookie, or URL path prefix) against the app's supported tags, then stamps Content-Language and Vary: Accept-Language and exposes the served tag to handlers via the request context:

cfg := httputil.DefaultLanguageConfig()
cfg.SupportedTags = []string{"en", "de"}

handler := httputil.Language(cfg)(mux)

The default matcher falls back by primary subtag (de-AT serves de); supply a TagMatcher for full BCP 47 matching via golang.org/x/text. Extractors compose with LanguageExtractorChain — the first extractor producing a supported tag wins, so a cookie or path-prefix leg can outrank the header.

Ordering: Place Language outside CSRFMiddleware (the 403 page can localize, which needs the language context resolved) and inside CORS (CORS must stay outermost to answer preflights). Declared tag spellings are preserved — zh-Hans is served as zh-Hans; only comparisons are case-insensitive. ETag must also sit inside (after) Language: an ETag computed before language selection would let a cache serve language A's body to language B's conditional request.

handler := httputil.Chain(
    mux,
    httputil.CORS(httputil.DefaultCORSConfig()), // outermost: answers preflights
    httputil.CSRFMiddleware(httputil.CSRFConfig{}),
    httputil.Language(cfg),
    etagMiddleware, // innermost of the pair: hashes AFTER language selection
)

Error Classification

ResponseRecorder, compressWriter, and CSRFMiddleware errors are classified with behavioral families via go-error-family:

Source Error Code Family Retryable When
Write http.write_failed Transient Yes Underlying ResponseWriter.Write fails
Hijack http.hijack_unsupported Infrastructure No Underlying writer doesn't implement Hijacker
Hijack http.hijack_failed Transient Yes Underlying Hijack call fails
Compress http.compress_write_failed Transient Yes Compression writer Write/Close fails
CSRF csrf_invalid Rejection No CSRF token missing, malformed, or mismatched
CSRF csrf.origin_attestation_conflict Rejection No Sec-Fetch-Site: same-origin attestation contradicted by a cross-origin Origin header (forged attestation)
CSRF csrf_config Infrastructure No CSRF configuration invalid
CSRF csrf.max_age_negative Rejection No CSRFConfig.MaxAge is negative
CSRF csrf.trusted_origin_invalid Rejection No TrustedOrigins entry is not a usable scheme://host origin
CORS cors.methods_empty Rejection No CORSConfig.AllowedMethods is empty
Language language.* Rejection No LanguageConfig invalid (empty tags, invalid tag charset, unsupported default, nil extractor)
Compress compression.incompressible_prefix_invalid Rejection No IncompressibleTypes entry is empty or lacks a slash
Compress compression.absent_encoding_invalid Rejection No AbsentEncoding is not a known AbsentEncodingPolicy constant
Decompress decompression.encoding_unrecognized Rejection No Encodings entry other than gzip/deflate
Decompress decompression.encoding_duplicate Rejection No Same encoding listed twice
Ratelimit ratelimit.* Rejection No KeyedRateLimiterConfig invalid (zero limit/window, negative TTL, Burst above math.MaxInt32)

Call RegisterErrorClassifications() at startup to enable classification of stdlib HTTP errors and register error message templates.

Conditional-request outcomes have upstream classification guidance in go-error-family (README, "Conditional Requests"): 428 Precondition Required is a Rejection (an omitted precondition is incomplete input), 412 Precondition Failed is a Conflict via NewConflict(...).WithHTTPStatus(412) (a state clash — refresh and reapply), and 304 Not Modified is a success-path response, not an error — which is how the ETag middleware above produces it.

API

Function Signature Purpose
CORS func(CORSConfig) func(http.Handler) http.Handler CORS middleware factory
DefaultCORSConfig func() CORSConfig Permissive dev config (allows all origins)
ClientIP func(*http.Request) string Extract client IP from proxied request
ClientIPMiddleware func(http.Handler) http.Handler Store client IP in request context
ClientIPFromContext func(context.Context) string Retrieve stored client IP
WithClientIP func(context.Context, string) context.Context Store client IP in context
NewResponseRecorder func(http.ResponseWriter) *ResponseRecorder Wrap writer to capture status
Chain func(http.Handler, ...func(http.Handler) http.Handler) http.Handler Compose middleware
Compose func(...Middleware) Middleware Reusable middleware bundle (first = outermost)
SecurityHeaders func(SecurityHeadersConfig) func(http.Handler) http.Handler Security response headers
DefaultSecurityHeadersConfig func() SecurityHeadersConfig Sensible security defaults
RequestID func(RequestIDConfig) func(http.Handler) http.Handler Request ID propagation/generation
DefaultRequestIDConfig func() RequestIDConfig Default X-Request-ID config
RequestIDFromContext func(context.Context) string Retrieve stored request ID
Recovery func(*slog.Logger) func(http.Handler) http.Handler Panic recovery
RecoveryWithConfig func(RecoveryConfig) Middleware Panic recovery with an OnPanic observability hook
Timeout func(time.Duration) func(http.Handler) http.Handler Request deadline enforcement
Logging func(*slog.Logger) func(http.Handler) http.Handler Structured request logging
LoggingFromContext func(*slog.Logger, func(context.Context) *slog.Logger) Middleware Request logging with the logger resolved per request from context
MaxBodySize func(int64) func(http.Handler) http.Handler Request body size limit
Compression func(CompressionConfig) func(http.Handler) http.Handler Negotiated response compression
DefaultCompressionConfig func() CompressionConfig gzip/deflate defaults
DefaultWriterFactories func() map[string]WriterFactory Built-in gzip/deflate/identity factories
DefaultWriterFactoriesForLevel func(int) map[string]WriterFactory Built-in factories at a given compression level
GzipWriterFactory func(int) WriterFactory Stdlib gzip factory at a given level
DeflateWriterFactory func(int) WriterFactory Stdlib flate/raw-deflate factory
DefaultIncompressibleTypes func() []string Default content-type deny-list for compression
Decompression func(DecompressionConfig) func(http.Handler) http.Handler Request body decompression + bomb protection
DefaultDecompressionConfig func() DecompressionConfig gzip/deflate defaults
KeyedRateLimiterMiddleware func(KeyedRateLimiterConfig) func(http.Handler) http.Handler Per-key rate limiting with eviction
Language func(LanguageConfig) func(http.Handler) http.Handler Content-language negotiation (Content-Language + Vary stamping)
NewKeyedRateLimiter func(KeyedRateLimiterConfig) *KeyedRateLimiter Rate limiter with monitoring API
DefaultKeyedRateLimiterConfig func() KeyedRateLimiterConfig Default per-key rate limit config
KeyExtractorFromTrustedClientIP func([]string) (KeyExtractor, error) Client-IP keying gated by trusted-proxy CIDRs
ParseTrustedProxies func([]string) (TrustedProxySet, error) Parsed, immutable trusted-proxy set (.ClientIP/.Contains/.KeyExtractor)
CSRFMiddleware func(CSRFConfig) func(http.Handler) http.Handler CSRF protection middleware
CSRFResponseHeaderMiddleware func(http.Handler) http.Handler Auto-set CSRF token in response header
Nonce func(NonceConfig) func(http.Handler) http.Handler Per-request CSP nonce (context + optional CSP header)
DefaultNonceConfig func() NonceConfig Default CSP nonce config (20 bytes)
NonceAttr func(*http.Request) string Ready-to-use nonce="..." HTML attribute
ValidateCSRF func(*http.Request, CSRFConfig) (bool, *httptest.ResponseRecorder) Standalone CSRF validation
ServerTimingMiddleware func() func(http.Handler) http.Handler W3C Server-Timing middleware (server_timing module)
ServerTimingMiddlewareWhen func(func(*http.Request) bool) func(http.Handler) http.Handler Conditional Server-Timing (server_timing module)
endpoints.NewRegistry func() *endpoints.Registry Route registry for the /endpoints listing (endpoints module)
endpoints.Registry.Handle func(*http.ServeMux, string, http.Handler, string) error Mount a handler on the mux AND record its route
endpoints.Registry.HandleFunc func(*http.ServeMux, string, func(http.ResponseWriter, *http.Request), string) error Handle for a bare handler function
endpoints.Registry.Routes func() []endpoints.Route Registered routes, sorted defensive copy
endpoints.Registry.Handler func() http.Handler The content-negotiated listing handler (GET/HEAD, 405 otherwise)
endpoints.Register func(*http.ServeMux, *endpoints.Registry) error Mount the listing at DefaultPattern (GET /endpoints)
endpoints.Route struct { Method, Pattern, Summary string } One recorded route
endpoints.Response struct { Endpoints []endpoints.Route } Listing payload rendered as JSON or HTML
endpoints.DefaultPattern const "GET /endpoints" Pattern Register mounts by default
ParseUintQuery func(*http.Request, string) uint Parse uint from query param
Metrics func(MetricsConfig) func(http.Handler) http.Handler Request metrics recording
DefaultMetricsConfig func() MetricsConfig Default metrics config
NewMiddlewareStack func() *MiddlewareStack Named middleware stack builder
MiddlewareStack.Middleware() func() Middleware Nest the whole stack as one middleware
MiddlewareFunc.Then func(http.Handler) http.Handler Value-level chaining; nil wires a 500-stub handler
DetectCapabilities func(http.ResponseWriter) Capabilities Report Hijacker/Flusher support
RegisterErrorClassifications func() Register stdlib error sentinels + templates
NewServer func(ServerConfig, http.Handler) (*Server, error) Configurable HTTP server with timeouts
NewServerListener func(net.Listener, ServerConfig, http.Handler) (*Server, error) Serve on a caller-bound listener (wrappers own the bind)
Server.ListenerAddr() func() (net.Addr, bool) Resolved listener address (:0 → real port); false when not listening
DefaultServerConfig func() ServerConfig Sensible server timeout defaults
RegisterHealth func(*http.ServeMux) Register /health + /live + /ready
HealthHandler func() http.HandlerFunc Simple {"status":"up"} handler
LiveHandler func() http.HandlerFunc Kubernetes liveness probe handler
ReadyHandler func() http.HandlerFunc Kubernetes readiness probe handler
ReadyHandlerWithProbe func(func() bool) http.HandlerFunc Readiness with dependency probe (200/503)

CORSConfig fields

Field Type Default Description
AllowedOrigins []string ["*"] Origins permitted in CORS responses (supports *.example.com)
AllowedMethods []string ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] Allowed HTTP methods
AllowedHeaders []string ["Content-Type", "Authorization", "X-Request-ID"] Accepted request headers
ExposedHeaders []string [] Headers the browser may access
AllowCredentials bool false Whether to send credentials
MaxAge int 86400 Preflight cache duration in seconds
AllowAllOrigins bool true Respond with * for any origin
OptionsPassthrough bool false Forward OPTIONS to the next handler
DenyUnmatched bool true Withhold Allow-Origin for origins not in AllowedOrigins (secure by default)
AllowPrivateNetwork bool false Answer Chrome Local Network Access preflights (preflight responses only)

AllowPrivateNetwork is independent of origin matching: a denied-origin preflight (DenyUnmatched, no Allow-Origin) still carries the header — the browser already fails that response at the missing Allow-Origin, so no capability is granted. Note the MaxAge interplay: browsers cache preflight results (including the LNA grant) for Access-Control-Max-Age seconds, so lowering MaxAge also shortens how long an LNA allowance survives a config rollback.

ResponseRecorder methods

Method Returns Description
Status() int Captured status code (0 if WriteHeader not called)
WroteHeader() bool Whether WriteHeader was called
HeaderSnapshot() http.Header Isolated copy of response headers
WriteHeader(int) — Capture status and delegate
Write([]byte) (int, error) Write body, implicitly set 200
Flush() — Delegate if underlying writer supports http.Flusher
Hijack() (net.Conn, *bufio.ReadWriter, error) Delegate if underlying writer supports http.Hijacker

CompressionConfig fields

Field Type Default Description
MinSize int 512 Minimum response body size before compression is attempted
Level int gzip.DefaultCompression Compression level used when WriterFactories is not supplied; applies to both gzip and deflate
WriterFactories map[string]WriterFactory gzip, deflate, identity Encoding-name → factory map; replace or extend
IncompressibleTypes []string DefaultIncompressibleTypes() Content-types to skip (nil = defaults, empty = compress all)

DecompressionConfig fields

Field Type Default Description
Encodings []string gzip, deflate Request body encodings to decompress; empty = both defaults
MaxDecompressionSize int64 16777216 Max decompressed body size in bytes to prevent zip bombs (16 MiB); 0 selects the 16 MiB default (no unlimited option)

KeyedRateLimiterConfig fields

Field Type Default Description
Limit uint 100 Maximum requests per Window per key
Window time.Duration 1m Time window for the limit
Burst uint 0 (= Limit) Maximum burst size (can exceed Limit)
KeyExtractor KeyExtractor KeyExtractorFromClientIP() Extracts the rate-limit key from the request
TTL time.Duration 10m How long idle entries are kept before eviction
MaxKeys uint 0 (unbounded) Caps tracked keys; oldest evicted at capacity
OnAllowed func(*http.Request) nil Callback when a request passes
OnRejected func(*http.Request, string) nil Callback when rejected (receives retryAfter)
RejectionHandler func(w, r, retryAfter) nil (429 + Retry-After) Custom handler for rejected requests

CSRFConfig fields

Field Type Default Description
CookieName string "csrf_token" Name of the CSRF cookie
HeaderName string "X-Csrf-Token" Request header containing the CSRF token (canonical MIME spelling; header names are case-insensitive on the wire)
FieldName string "csrf_token" Form field name for the CSRF token
MaxAge time.Duration 24h Cookie max age
Secure bool false Sets the Secure flag on the cookie (set true in production; with SameSite=None the constructor falls back to Secure=true)
SameSite http.SameSite SameSiteLaxMode SameSite attribute on the cookie
Domain string "" (host-only) Cookie domain
Path string "/" Cookie path
TrustedOrigins []string nil Origins allowed for cross-domain CSRF (each must be a scheme://host origin; parsing is all-or-nothing — one bad entry means no trusted origins)
TrustedProxies []string nil IP/CIDR of reverse proxies that may strip origin headers
AllowPlaintextBypass bool false Allow plaintext-HTTP origin bypass for all non-TLS requests (insecure)
BootLogUntrustedForwardedProto bool false Opt-in boot warning when TrustedProxies is empty (XFP trust path silently inactive behind a TLS-terminating proxy)
AllowInsecureSameSiteNone bool false Keep SameSite=None without Secure instead of the Secure=true fallback (legacy clients only; browsers refuse to store the cookie)
ErrorHandler ErrorHandler nil (403 + body) Custom handler for CSRF validation failures

When SameSite is None and Secure is false, CSRFMiddleware (and InvalidateCSRFCookie) fall back to Secure=true at construction instead of honoring the combination verbatim: rfc6265bis §5.7 makes such cookies unstorable in current browsers, so the previously-honored misconfiguration meant every state-changing browser request failed CSRF validation. The fallback keeps the None intent (cross-site cookies still work on HTTPS deployments), logs csrf_samesite_insecure, and is skipped verbatim with AllowInsecureSameSiteNone: true for legacy-client deployments. ConfigureNosurfHandler applies no fallback — it is the verbatim low-level configurator.

MetricsConfig fields

Field Type Default Description
Recorder MetricsRecorder nil Receives one observation per request (required)
PathFunc func(*http.Request) string nil (r.URL.Path) Extracts the path to record from the request

SecurityHeadersConfig fields

Field Type Default Description
ContentTypeNosniff bool true Set X-Content-Type-Options: nosniff
FrameOptions string "DENY" X-Frame-Options value (empty = header omitted)
StrictTransportSecurity string "" Strict-Transport-Security value (empty = omitted)
ReferrerPolicy string "strict-origin-when-cross-origin" Referrer-Policy value (empty = omitted)
ContentSecurityPolicy string "" Content-Security-Policy value (empty = omitted)

RequestIDConfig fields

Field Type Default Description
ResponseHeader string "X-Request-ID" Response header to set with the resolved request ID
GenerateID func() string Time-ordered hex ID generator invoked when no incoming header is present
IncomingHeader string "X-Request-ID" Incoming request header to read the upstream ID from

ServerConfig fields

Field Type Default Description
Addr string ":8080" Listen address
ReadTimeout time.Duration 10s Maximum duration for reading the entire request
ReadHeaderTimeout time.Duration 5s Maximum duration for reading request headers
WriteTimeout time.Duration 30s Maximum duration before timing out writes
IdleTimeout time.Duration 60s Maximum time to wait for the next request on a connection

Handling errors from httputil

Every error this package produces is classified via go-error-family: it carries a machine-readable code (cors.max_age_negative), a behavioral family (Rejection, Transient, ...), and factual context (the offending field value). Three questions cover every consumer need:

// 1. Which component failed? Match by domain (the code's prefix).
if httputil.InDomain(err, httputil.Domain("cors")) {
    // a CORS misconfiguration, not a network failure
}

// Route a CSRF rejection by its exact code — e.g. a 403 carrying
// csrf.origin_attestation_conflict means a client sent a forged
// Sec-Fetch-Site: same-origin attestation next to a cross-origin Origin.
// That is forged client input (Rejection family): fix or block the client,
// never retry.
if httputil.InDomain(err, httputil.Domain("csrf")) {
    if coded, ok := errors.AsType[errorfamily.Coded](err); ok &&
        coded.ErrorCode() == "csrf.origin_attestation_conflict" {
        log.Printf("forged CSRF attestation from %s", r.RemoteAddr)
    }
}

// 2. What is the exact failure? Read the code via the Coded interface.
if coded, ok := errors.AsType[errorfamily.Coded](err); ok {
    log.Printf("code=%s", coded.ErrorCode()) // e.g. "server.timeout_ordering"
}

// 3. Should I retry? Ask the family.
if errorfamily.IsRetryable(err) { backoffAndRetry(err) }

Conventions:

  • Codes are hierarchical: domain.specific_failure (e.g. compression.qvalue_too_large). Config-validation codes are Rejection (fix the config, never retry); runtime write/hijack failures are Transient; shutdown and pool-contract violations are Infrastructure.
  • Sentinels still work: errors.Is(err, ErrCSRFInvalid) matches by code and family, including through wrapped chains.
  • Message templates: call httputil.RegisterErrorClassifications() once at startup to register user-facing what/why/fix/escape templates for every code; retrieve them with errorfamily.TemplateForCode.
  • Constructor logging: middleware constructors log invalid configs (validate-and-log, not abort) with structured code, family, and domain fields, so log pipelines can route misconfigurations without parsing messages.

Design

  • Stdlib-first — all middleware uses func(http.Handler) http.Handler, compatible with any Go HTTP framework
  • Round-trip fuzz invariants — every response-transforming middleware ships a decode-and-compare fuzz invariant (e.g. FuzzCompression gunzips every negotiated-gzip response and compares bytes); the invariant caught a real exact-fill duplication bug that 96.9% line coverage had missed. The nightly fuzz workflow runs all 30 of the repo's fuzz targets at 5 minutes each in CI.
  • Classified errors — ResponseRecorder errors carry behavioral families (Transient, Infrastructure) and structured context via go-error-family for observability and retry logic
  • Minimal dependencies — go-error-family (same author, zero transitive deps), go-etag (same author, ETag conditional requests), golang.org/x/time (canonical Go rate-limit extension), and justinas/nosurf (CSRF protection).

Middleware Ordering

Chain applies middleware in reverse declaration order so the first argument is outermost:

handler := Chain(mux,
    Logging(logger),          // outermost — logs first, sees final status
    Recovery(logger),         // catches panics before they reach Logging
    Timeout(30*time.Second),  // enforces deadline
    RequestID(DefaultRequestIDConfig()),
    SecurityHeaders(DefaultSecurityHeadersConfig()),
    Nonce(DefaultNonceConfig()), // after SecurityHeaders so nonce CSP wins
    CORS(DefaultCORSConfig()),
)

Ordering at a glance (outermost first):

flowchart LR
    A[Logging] --> B[Recovery] --> C[Timeout] --> D[RequestID] --> E[SecurityHeaders]
    E --> F[Nonce] --> G[CORS] --> H[Decompression] --> I[MaxBodySize] --> J[Compression] --> K[ETag] --> M[Mux]
Loading

Decompression should be placed outer so downstream middleware (e.g., MaxBodySize) sees the decompressed body size:

// Decompression outer: handlers see decompressed bytes.
// MaxBodySize limits the decompressed size, not the compressed size.
handler := Chain(mux, Decompression(cfg), MaxBodySize(1<<20))

ETag must be placed inside (after) Compression so it hashes the uncompressed body. If placed before Compression, the ETag would be computed over the compressed bytes — different content encodings on the wire would yield different ETags for the same logical resource, defeating conditional caching:

// Compression outer, ETag inner: ETag hashes the body the handler produced,
// not the bytes that left over the wire. All clients see the same ETag value
// regardless of which Accept-Encoding they negotiated.
handler := Chain(mux, Compression(cfg), etag.New(etag.DefaultETagConfig()))

Compression Extensibility

The default configuration supports gzip and deflate. Brotli, zstd, and other modern encodings are supported via the WriterFactory plugin interface without adding dependencies to the core library.

cfg := httputil.DefaultCompressionConfig()
cfg.WriterFactories["zstd"] = func(dst io.Writer) (io.WriteCloser, error) {
    return zstd.NewWriter(dst)
}
handler := httputil.Compression(cfg)(mux)

This keeps the core package dependency-free while allowing you to plug in any encoder that implements io.WriteCloser.

For a complete guide including pool reuse and brotli/zstd patterns, see the compression extensibility example.

Framework Integration

httputil composes cleanly with declarative API frameworks like Huma, which generates OpenAPI 3.1 + JSON Schema from Go types but deliberately ships no middleware. Both target the same Go 1.22+ http.ServeMux via the humago adapter — no third-party router required:

router := http.NewServeMux()
api := humago.New(router, huma.DefaultConfig("My API", "1.0.0"))
huma.Get(api, "/things/{id}", getThing) // huma: types, validation, OpenAPI

handler := httputil.Chain(router, // httputil: compression, security, logging
    httputil.Recovery(slog.Default()),
    httputil.Logging(slog.Default()),
    httputil.Compression(httputil.DefaultCompressionConfig()),
    httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig()),
    httputil.CORS(httputil.DefaultCORSConfig()),
)

See the full integration example and the detailed comparison.

For dependency injection and graceful lifecycle management, pair httputil with samber/do — httputil.Server.Shutdown(context.Context) error satisfies do.ShutdownerWithContextAndError structurally, so the container discovers and shuts down the HTTP server automatically. See the composition-root example.

For observability, see the Prometheus MetricsRecorder example. To build a secure middleware baseline once and apply it to many handlers (or nest whole stacks), see Composing Middleware Bundles.

Quality Gates

This project maintains strict quality standards enforced in CI:

Gate Command Status
Tests go test -race -count=1 ./... Passing
Race stress go test -race -count=10 ./... Passing
Coverage go test -coverprofile=coverage.out ./... 98.7% httputil / 98.4% httpspec (threshold: 95%)
Lint golangci-lint run (~70 linters) 0 issues
Vet go vet ./... Clean
Vulnerabilities govulncheck ./... None found
Nix flake nix flake check All checks passed
Module integrity go mod verify All modules verified

Development

go test ./...               # Run tests
go test -race ./...         # Race detection (REQUIRED for t.Parallel())
go test -race -count=10 ./...  # Stress race detection
go vet ./...                # Vet
go test -bench=. ./...      # Benchmarks
golangci-lint run           # Lint (~70 linters)
golangci-lint fmt           # Format (gofumpt + golines@120 + gci)
govulncheck ./...           # Vulnerability scan
nix flake check             # Nix flake validation

License

MIT — see LICENSE. The server_timing and endpoints sub-modules are MIT-licensed as well, each with its own LICENSE/LICENSE and README/README.

About

Composable HTTP middleware and server lifecycle for Go — CORS, CSRF, rate limiting, compression, security headers, CSP nonces, request IDs, timeouts, panic recovery, W3C Server-Timing, language negotiation, and health checks. Pure stdlib net/http, four dependencies.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages