middleware

package
v0.0.20 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 9, 2026 License: Apache-2.0 Imports: 13 Imported by: 1

Documentation

Overview

Package middleware provides reusable HTTP middleware for production hardening.

Components include:

  • ClientIPExtractor: Extracts real client IPs behind trusted reverse proxies
  • RateLimiter: Global and per-key rate limiting with configurable key functions
  • ConnLimiter: Concurrent connection limiting for WebSocket endpoints
  • OriginChecker: Origin allowlist for WebSocket upgrade requests
  • CORS: Origin-aware CORS header middleware
  • Recovery: Panic recovery with structured logging
  • RequestLogger: Structured HTTP request logging (includes request ID when available)
  • RequestID: Request ID generation/propagation via X-Request-Id header
  • BodyLimiter: Request body size limiting via http.MaxBytesReader
  • HealthCheck: Health/readiness endpoint handler for load balancers and k8s probes
  • Guard: Composable middleware chain

Helpers:

  • ApplyDefaults: Sets sensible timeout defaults on http.Server (note: SSE endpoints require WriteTimeout = 0 separately to prevent the server from closing long-lived streaming connections)

All components are nil-safe: a nil component acts as a no-op passthrough. No application-specific imports — designed for embedding in any Go HTTP server.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApplyDefaults added in v0.0.7

func ApplyDefaults(srv *http.Server)

ApplyDefaults sets sensible timeout defaults on an http.Server, only overwriting fields that are zero-valued.

Defaults:

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

For SSE or long-lived streaming endpoints, callers should either set WriteTimeout to a non-zero value before calling ApplyDefaults (it won't overwrite), or set WriteTimeout = 0 after calling this function to disable the write deadline for streaming responses.

Example

ExampleApplyDefaults demonstrates setting sensible timeout defaults on http.Server. For SSE or WebSocket endpoints, set WriteTimeout = 0 after calling ApplyDefaults to prevent the server from killing long-lived connections.

package main

import (
	"fmt"
	"net/http"

	mw "github.com/panyam/servicekit/middleware"
)

func main() {
	srv := &http.Server{Addr: ":8080"}
	mw.ApplyDefaults(srv)

	// For SSE/WebSocket servers, override WriteTimeout
	srv.WriteTimeout = 0

	fmt.Printf("ReadTimeout: %v, WriteTimeout: %v, IdleTimeout: %v\n",
		srv.ReadTimeout, srv.WriteTimeout, srv.IdleTimeout)
}
Output:
ReadTimeout: 10s, WriteTimeout: 0s, IdleTimeout: 2m0s

func CORS

func CORS(checker *OriginChecker) func(http.Handler) http.Handler

CORS returns middleware that sets CORS headers based on an OriginChecker.

Behavior:

  • If checker is nil (no allowlist): Access-Control-Allow-Origin: *
  • If checker is set and the request Origin matches: reflect the origin back
  • If checker is set and the request Origin doesn't match: no CORS headers (browser will block the response)
  • OPTIONS preflight requests are handled and return 204

This replaces the naive "Access-Control-Allow-Origin: *" pattern with origin validation. The same OriginChecker used for WebSocket origin checks is reused here for consistency.

func ClientIP

func ClientIP(r *http.Request) string

ClientIP extracts the real client IP using the default extractor. Configure trusted proxies via SetTrustedProxies.

func ContextWithRequestID added in v0.0.7

func ContextWithRequestID(ctx context.Context, id string) context.Context

ContextWithRequestID returns a new context with the given request ID.

func Recovery

func Recovery(next http.Handler) http.Handler

Recovery returns middleware that recovers from panics in downstream handlers. On panic, it logs the error and stack trace, then returns 500 to the client. Without this, a single bad request can crash the entire server.

func RequestIDFromContext added in v0.0.7

func RequestIDFromContext(ctx context.Context) string

RequestIDFromContext extracts the request ID from context, or "" if absent.

func RequestLogger

func RequestLogger(skipPaths ...string) func(http.Handler) http.Handler

RequestLogger logs HTTP requests with method, path, status, duration, and client IP. When a request ID is present in the context (set by the RequestID middleware), it is automatically included in the log output as "request_id". Skips logging for specified paths (e.g. /healthz for noisy liveness probes).

func RequireContentType added in v0.0.20

func RequireContentType(accepted ...string) func(http.Handler) http.Handler

RequireContentType returns middleware that rejects POST, PUT, and PATCH requests whose Content-Type header does not match any of the accepted types (prefix match). GET, DELETE, OPTIONS, and HEAD requests are passed through since they typically have no body.

Returns 415 Unsupported Media Type for requests with a missing or mismatched Content-Type. This is a defense-in-depth measure against CSRF via cross-origin form submissions (browsers send forms as application/x-www-form-urlencoded without CORS preflight).

Example:

// Single type
mux.Handle("/api", middleware.RequireContentType("application/json")(handler))

// Multiple types
mux.Handle("/rpc", middleware.RequireContentType("application/json", "application/json-rpc")(handler))

func SetTrustedProxies

func SetTrustedProxies(cidrs []string)

SetTrustedProxies configures the default (package-level) extractor. This is a convenience for simple use cases; prefer NewClientIPExtractor for instance isolation.

Types

type BodyLimiter added in v0.0.7

type BodyLimiter struct {
	// contains filtered or unexported fields
}

BodyLimiter limits the size of incoming request bodies using http.MaxBytesReader. When the handler reads past the limit, MaxBytesReader returns an error and the request fails.

Nil-safe: a nil BodyLimiter passes requests through unchanged.

func NewBodyLimiter added in v0.0.7

func NewBodyLimiter(maxBytes int64) *BodyLimiter

NewBodyLimiter creates a limiter with the given max body size in bytes. Returns nil if maxBytes <= 0 (caller should skip the middleware).

func (*BodyLimiter) Middleware added in v0.0.7

func (b *BodyLimiter) Middleware(next http.Handler) http.Handler

Middleware returns an HTTP middleware that limits request body size. On a nil receiver, returns the handler unchanged.

type ClientIPExtractor

type ClientIPExtractor struct {
	// contains filtered or unexported fields
}

ClientIPExtractor extracts the real client IP from requests, honoring X-Forwarded-For and X-Real-IP headers only when the direct connection comes from a trusted proxy CIDR.

Usage:

// Trust Caddy on localhost and Docker bridge network
extractor := NewClientIPExtractor([]string{"127.0.0.1/32", "172.17.0.0/16", "::1/128"})
ip := extractor.ClientIP(r)

// Trust all proxies (suitable for single-proxy deployments)
extractor := NewClientIPExtractor(nil)

func NewClientIPExtractor

func NewClientIPExtractor(cidrs []string) *ClientIPExtractor

NewClientIPExtractor creates an extractor with the given trusted proxy CIDRs. When cidrs is nil/empty, all proxies are trusted (backwards-compatible default).

func (*ClientIPExtractor) ExtractClientIP

func (e *ClientIPExtractor) ExtractClientIP(r *http.Request) string

ExtractClientIP extracts the real client IP from the request.

If trusted proxies are configured, X-Forwarded-For is only honored when the direct connection (RemoteAddr) comes from a trusted proxy CIDR. Otherwise, the direct RemoteAddr is used.

If no trusted proxies are configured (default), X-Forwarded-For is always trusted (backwards-compatible for deployments behind a proxy).

type ConnLimiter

type ConnLimiter struct {
	// contains filtered or unexported fields
}

ConnLimiter limits the number of concurrent active requests to a handler. Designed for WebSocket endpoints where each connection holds resources for the lifetime of the session.

When the limit is reached, new requests receive 503 Service Unavailable. The counter decrements when the request handler returns (connection closes).

func NewConnLimiter

func NewConnLimiter(max int64) *ConnLimiter

NewConnLimiter creates a limiter with the given max concurrent connections. Pass 0 for unlimited (returns nil — caller should skip the middleware).

func (*ConnLimiter) Active

func (c *ConnLimiter) Active() int64

Active returns the current number of active connections.

func (*ConnLimiter) Middleware

func (c *ConnLimiter) Middleware(next http.Handler) http.Handler

Middleware returns an HTTP middleware that enforces the connection limit.

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard composes multiple middleware into a single wrapper. Middleware are applied in the order they are added via Use — the first middleware added is the outermost (runs first on request, last on response).

Usage:

g := &Guard{}
g.Use(originChecker.Middleware, rateLimiter.Middleware(nil), auth.Middleware, connLimiter.Middleware)
http.Handle("/ws", g.Wrap(wsHandler))
Example

ExampleGuard demonstrates composing middleware into a chain using Guard. Middleware are applied in Use() order — the first added is outermost (runs first on request, last on response). Nil middleware are silently skipped.

package main

import (
	"fmt"
	"net/http"

	mw "github.com/panyam/servicekit/middleware"
)

func main() {
	// Create middleware components
	rl := mw.NewRateLimiter(mw.RateLimitConfig{
		GlobalPerSec: 100,
		PerKeyPerSec: 10,
	})
	oc := mw.NewOriginChecker([]string{
		"https://example.com",
		"https://*.example.com",
	})

	// Compose into a Guard chain
	g := &mw.Guard{}
	g.Use(
		oc.Middleware,      // Check origin first
		rl.Middleware(nil), // Then rate limit (nil key = use ClientIP)
		mw.Recovery,        // Catch panics
	)

	// Wrap your handler
	handler := g.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	}))

	http.Handle("/api", handler)
	_ = handler
	fmt.Println("Guard chain configured")
}
Output:
Guard chain configured

func (*Guard) Use

func (g *Guard) Use(mw ...func(http.Handler) http.Handler)

Use adds middleware to the guard chain. Nil middleware are silently skipped.

func (*Guard) Wrap

func (g *Guard) Wrap(h http.Handler) http.Handler

Wrap applies all configured middleware to a handler. On a nil receiver, returns the handler unchanged.

type HealthCheck added in v0.0.7

type HealthCheck struct {
	// contains filtered or unexported fields
}

HealthCheck is an HTTP handler that serves health/readiness endpoints for load balancers and Kubernetes probes. It implements http.Handler and should be mounted directly on a mux, not used as middleware.

Nil-safe: a nil *HealthCheck returns 404.

func NewHealthCheck added in v0.0.7

func NewHealthCheck(opts ...HealthOption) *HealthCheck

NewHealthCheck creates a health check handler with the given options. Default path is "/healthz".

func (*HealthCheck) Path added in v0.0.7

func (h *HealthCheck) Path() string

Path returns the configured endpoint path for mux registration.

func (*HealthCheck) ServeHTTP added in v0.0.7

func (h *HealthCheck) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler. On a nil receiver, returns 404.

type HealthOption added in v0.0.7

type HealthOption func(*HealthCheck)

HealthOption configures the health check handler.

func WithPath added in v0.0.7

func WithPath(path string) HealthOption

WithPath sets the health endpoint path (default "/healthz").

func WithReadyFunc added in v0.0.7

func WithReadyFunc(fn func() bool) HealthOption

WithReadyFunc sets an optional readiness callback. When set and returning false, the endpoint returns 503 with {"status":"not ready"}.

type OriginChecker

type OriginChecker struct {
	// contains filtered or unexported fields
}

OriginChecker rejects requests whose Origin header doesn't match an allowlist. Designed for WebSocket endpoints where CORS headers alone don't prevent cross-origin connections.

Matching rules:

  • Exact match on scheme+host (port-insensitive for 80/443)
  • Wildcard subdomain: "*.example.com" matches "foo.example.com"
  • "localhost" matches any localhost origin regardless of port
  • Empty allowlist = allow all (no-op)
  • Missing Origin header = blocked (unless allowlist is empty)

func NewLocalhostOriginChecker added in v0.0.18

func NewLocalhostOriginChecker() *OriginChecker

NewLocalhostOriginChecker creates an OriginChecker that only allows localhost origins (localhost, 127.0.0.1, ::1). This is the secure default for local development servers that don't have an explicit allowlist.

Unlike NewOriginChecker(nil) which returns nil (allow-all), this returns a restrictive checker that blocks remote origins.

func NewOriginChecker

func NewOriginChecker(origins []string) *OriginChecker

NewOriginChecker creates a checker from a list of allowed origins. Accepts formats: "https://example.com", "*.example.com", "localhost". Returns nil if the list is empty (caller should skip the check).

Example

ExampleNewOriginChecker demonstrates WebSocket origin validation with exact matches, wildcard subdomains, and localhost support. The checker is typically used via Guard middleware, but Check() can be called directly.

package main

import (
	"fmt"

	mw "github.com/panyam/servicekit/middleware"
)

func main() {
	oc := mw.NewOriginChecker([]string{
		"https://example.com", // exact match
		"*.example.com",       // wildcard subdomain
		"localhost",           // any localhost port
	})

	fmt.Println(oc.Check("https://example.com"))     // true
	fmt.Println(oc.Check("https://app.example.com")) // true
	fmt.Println(oc.Check("http://localhost:3000"))   // true
	fmt.Println(oc.Check("https://evil.com"))        // false
}
Output:
true
true
true
false

func (*OriginChecker) Check

func (c *OriginChecker) Check(origin string) bool

Check returns true if the origin is allowed.

func (*OriginChecker) CheckRequest added in v0.0.18

func (c *OriginChecker) CheckRequest(r *http.Request) bool

CheckRequest validates an HTTP request's origin using both Origin and Host headers. This implements the full DNS rebinding protection pattern:

  • Origin header present → delegates to Check(origin)
  • Origin absent, Host is localhost → allow (local dev tools like curl)
  • Origin absent, Host is remote → reject (potential DNS rebinding)
  • No Origin or Host info → allow (same-origin or non-browser client)

Nil-safe: calling on a nil *OriginChecker allows all requests.

func (*OriginChecker) Middleware

func (c *OriginChecker) Middleware(next http.Handler) http.Handler

Middleware returns an HTTP middleware that rejects disallowed origins. Only applies to WebSocket upgrade requests (Connection: Upgrade). Non-upgrade requests pass through unchanged.

type RateLimitConfig

type RateLimitConfig struct {
	GlobalPerSec  float64       // max requests/sec globally (0 = unlimited)
	PerKeyPerSec  float64       // max requests/sec per key (0 = unlimited)
	PerKeyBurst   int           // burst allowance per key
	KeyLimiterTTL time.Duration // cleanup interval for stale per-key limiters
}

RateLimitConfig controls rate limiting.

func DefaultRateLimitConfig

func DefaultRateLimitConfig() RateLimitConfig

DefaultRateLimitConfig returns sensible defaults.

type RateLimiter

type RateLimiter struct {
	Config RateLimitConfig

	// OnRejected is called when a request is rate-limited.
	// The key argument is the rate limit key (e.g., IP address, subject ID).
	OnRejected func(key string)
	// contains filtered or unexported fields
}

RateLimiter enforces global and per-key rate limits.

func NewRateLimiter

func NewRateLimiter(cfg RateLimitConfig) *RateLimiter

NewRateLimiter creates a rate limiter. Returns nil if both limits are 0.

Example

ExampleNewRateLimiter demonstrates per-key rate limiting with a custom key function. The default key is client IP; override it to rate limit per user, per API key, or per any request attribute.

package main

import (
	"fmt"
	"net/http"

	mw "github.com/panyam/servicekit/middleware"
)

func main() {
	rl := mw.NewRateLimiter(mw.RateLimitConfig{
		GlobalPerSec: 100,
		PerKeyPerSec: 5,
		PerKeyBurst:  3,
	})

	// Rate limit by API key header instead of IP
	apiKeyFunc := func(r *http.Request) string {
		return r.Header.Get("X-API-Key")
	}

	handler := rl.Middleware(apiKeyFunc)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	}))

	_ = handler
	fmt.Println("Rate limiter configured")
}
Output:
Rate limiter configured

func (*RateLimiter) Allow

func (rl *RateLimiter) Allow(key string) bool

Allow checks both global and per-key rate limits. Returns false if rejected.

func (*RateLimiter) Middleware

func (rl *RateLimiter) Middleware(keyFunc func(*http.Request) string) func(http.Handler) http.Handler

Middleware returns an HTTP middleware that enforces rate limits. keyFunc extracts the rate limit key from the request. If nil, defaults to ClientIP.

type RequestID added in v0.0.7

type RequestID struct{}

RequestID generates or propagates request IDs via the X-Request-Id header. If the incoming request has an X-Request-Id header, it is preserved. Otherwise, a new 32-character hex string (16 random bytes) is generated. The ID is stored in the request context and set on the response header.

func NewRequestID added in v0.0.7

func NewRequestID() *RequestID

NewRequestID creates a RequestID middleware instance.

func (*RequestID) Middleware added in v0.0.7

func (rid *RequestID) Middleware(next http.Handler) http.Handler

Middleware returns an HTTP middleware that sets X-Request-Id on request and response. On a nil receiver, returns the handler unchanged.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL