Documentation
¶
Overview ¶
Package webhttp provides resilient server-side HTTP plumbing for building small services on top of net/http.
It bundles the pieces almost every server ends up hand-rolling:
- request-id injection plus one-line access logging (RequestLogger), whose two attacker-controlled attributes are bounded by default — the path to 512 bytes on a rune boundary (WithMaxLoggedPath re-sets the cap), the method to 24 bytes, beyond which it records a placeholder rather than a misleading prefix — so a megabyte URL cannot buy a megabyte log line, plus a metric hook whose (method, path) labels the LIBRARY derives and bounds, the method onto a closed ten-value set and the path onto the matched route (WithRecordRouteMetric, over the RouteMetricLabels primitive), so an app cannot forget the cardinality bound,
- a status recorder that stays transparent to http.ResponseController and also implements http.Flusher/http.Hijacker/io.ReaderFrom passthroughs, so both ResponseController-based and direct-type-assertion callers (plus the sendfile fast path) keep working (StatusRecorder),
- a composable middleware set: an ordering combinator (Chain), a panic recoverer (Recoverer), baseline response security headers (SecurityHeaders), access logging as middleware (Logging), a JSON per-route timeout (RouteTimeout), a shared token-bucket rate limiter (RateLimiter, with the SessionCreateRateLimit preset for heavy-child-spawning create endpoints), and a fixed Cache-Control: no-store setter for dynamic surfaces (NoStore, whose placement and override ordering stay app-owned), plus a spoof-aware client-IP resolver that reads X-Forwarded-For only from trusted proxy hops (ClientIP),
- an exact-match Host allowlist against DNS rebinding: an immutable parsed policy (ParseHostList, HostPolicy) applied as middleware or queried per request, built on a strict authority canonicalizer that rejects malformed Host values rather than repairing them (CanonicalHost), with an opt-in carve-out for loopback health checks and in-container clients (WithLoopbackExempt),
- an embedded-static file handler with construction-time content-hash ETags and precomputed gzip, with per-path cache policy left to the app (StaticHandler, WithStaticCacheControl),
- a CSP inline-script hash extractor for pinning script-src to the exact embedded page bytes instead of 'unsafe-inline' (InlineScriptHashes; the policy string itself stays app-owned, passed via WithCSP),
- JSON response and error helpers (WriteJSON, WriteJSONStatus, Ok, WriteError),
- request-prelude helpers for body limiting, method gating, and JSON decoding (LimitBody, RequireMethod, MethodNotAllowed, DecodeBody),
- a constant-time verifier for a single operator-configured static credential — an API key, bearer token, or basic-auth field — hashing the secret once at construction and comparing SHA-256 digests so no per-call timing varies with the secret, failing closed on an empty configured secret (NewStaticTokenVerifier),
- a configured-bind exposure classifier for boot-time policy — is this listen address loopback-only, exposed beyond loopback, or not host:port at all — with the malformed-input decision left to the app (ClassifyBind, ClassifyBindHost, BindClass),
- an HTTP readiness gate for load balancers (Ready, ReadinessHandler),
- a graceful server bootstrap (NewServer, Run) with net/http's own connection-level lines routed into slog at a caller-chosen level (WithSlogErrorLog), a grace expiry identifiable by its origin rather than by a bare deadline error (ErrShutdownGraceExpired), a bounded teardown wait that cannot misreport a completion as a timeout (AwaitDone), and a cause-aware predicate for telling a routine cancellation apart from a coincident fault (CausedByCancellation).
The middleware share the standard func(http.Handler) http.Handler shape (the Middleware alias) and compose with Chain, whose first-listed entry is the outermost wrapper. A typical stack is Chain(mux, Logging(), Recoverer(), SecurityHeaders()): logging outermost so a recovered panic is logged as its 500 rather than a misleading 200.
webhttp is the inbound-server counterpart to httpx (github.com/cplieger/httpx), which is the outbound-client toolkit: httpx makes resilient requests going OUT, webhttp handles the requests coming IN. The two are complementary and share no code.
The package has zero dependencies beyond the standard library. It ships the mechanism only; each consuming application layers its own route table, error taxonomy, and named helpers on top.
The sse subpackage (github.com/cplieger/webhttp/sse) adds a broadcast hub for Server-Sent Events: replay ring with Last-Event-ID resume, topic filtering, keepalives, client caps, and a shutdown drain gate. It is the streaming counterpart to the request/response helpers here (RouteTimeout deliberately cannot wrap a stream; the sse handler owns its own deadlines).
Index ¶
- Constants
- Variables
- func AwaitDone(ctx context.Context, done <-chan struct{}) bool
- func CanonicalHost(hostport string) string
- func CausedByCancellation(ctx context.Context, err error) bool
- func Chain(h http.Handler, mw ...Middleware) http.Handler
- func ClientIP(r *http.Request, trusted ...*net.IPNet) string
- func DecodeBody(w http.ResponseWriter, r *http.Request, v any, errMsg string) bool
- func DecodeBodyOptional(w http.ResponseWriter, r *http.Request, v any)
- func DecodeJSONInto(w http.ResponseWriter, r *http.Request, v any, maxBytes int64) error
- func InlineScriptHashes(html []byte) []string
- func InlineStyleHashes(html []byte) []string
- func JSONHeaders(w http.ResponseWriter)
- func LimitBody(w http.ResponseWriter, r *http.Request, maxBytes int64)
- func MethodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string)
- func NewRequestID() string
- func NewServer(handler http.Handler, opts ...ServerOption) *http.Server
- func Ok(w http.ResponseWriter)
- func ParseCIDRs(entries []string) (nets []*net.IPNet, invalid []string)
- func ReadinessHandler(c ReadinessChecker) http.HandlerFunc
- func RequestIDFromContext(ctx context.Context) string
- func RequestLogger(next http.Handler, opts ...LogOption) http.Handler
- func RequireMethod(w http.ResponseWriter, r *http.Request, method string) bool
- func RouteMetricLabels(r *http.Request) (method, path string)
- func RouteTimeout(h http.Handler, d time.Duration, msg string) http.Handler
- func Run(ctx context.Context, srv *http.Server, ln net.Listener, ...) error
- func SetAllow(w http.ResponseWriter, allowed ...string)
- func StaticHandler(fsys fs.FS, opts ...StaticOption) (http.Handler, error)
- func ValidRequestID(s string) bool
- func WithRequestID(ctx context.Context, id string) context.Context
- func WriteError(w http.ResponseWriter, r *http.Request, status int, code, msg string)
- func WriteJSON(w http.ResponseWriter, v any)
- func WriteJSONStatus(w http.ResponseWriter, code int, v any)
- type BindClass
- type ErrorResponder
- type ErrorResponse
- type HostAllowlistOption
- type HostPolicy
- type LogOption
- func ProbeLogLevel(paths ...string) LogOption
- func WithClientIP(trusted ...*net.IPNet) LogOption
- func WithClientIPFunc(fn func(*http.Request) string) LogOption
- func WithLogLevel(fn func(r *http.Request, status int) slog.Level) LogOption
- func WithLogger(l *slog.Logger) LogOption
- func WithMaxLoggedPath(n int) LogOption
- func WithPathFunc(fn func(*http.Request) string) LogOption
- func WithRecordMetric(fn func(method, path string, status int, d time.Duration)) LogOption
- func WithRecordMetricRequest(fn func(r *http.Request, status int, d time.Duration)) LogOption
- func WithRecordRouteMetric(fn func(method, path string, status int, d time.Duration)) LogOption
- func WithSkipFunc(fn func(*http.Request) bool) LogOption
- func WithSkipPaths(paths ...string) LogOption
- func WithSkipUpgrades() LogOption
- func WithTemplatePathsUnder(prefixes ...string) LogOption
- type Middleware
- func Logging(opts ...LogOption) Middleware
- func NoStore() Middleware
- func RateLimiter(burst int, interval time.Duration, opts ...RateLimitOption) Middleware
- func Recoverer(opts ...RecoverOption) Middleware
- func SecurityHeaders(opts ...SecurityOption) Middleware
- func SessionCreateRateLimit(path string) Middleware
- type RateLimitOption
- type ReadinessChecker
- type Ready
- type RecoverOption
- type RunOption
- type SecurityOption
- func WithCOOP(v string) SecurityOption
- func WithCSP(policy string) SecurityOption
- func WithFrameOptions(v string) SecurityOption
- func WithHSTS(maxAge time.Duration, includeSubdomains, preload bool) SecurityOption
- func WithPermissionsPolicy(v string) SecurityOption
- func WithReferrerPolicy(v string) SecurityOption
- type ServerOption
- func WithErrorLog(l *log.Logger) ServerOption
- func WithIdleTimeout(d time.Duration) ServerOption
- func WithMaxHeaderBytes(n int) ServerOption
- func WithReadHeaderTimeout(d time.Duration) ServerOption
- func WithReadTimeout(d time.Duration) ServerOption
- func WithSlogErrorLog(level slog.Level) ServerOption
- func WithWriteTimeout(d time.Duration) ServerOption
- type StaticOption
- type StaticTokenVerifier
- type StatusRecorder
- func (s *StatusRecorder) Flush()
- func (s *StatusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
- func (s *StatusRecorder) ReadFrom(src io.Reader) (int64, error)
- func (s *StatusRecorder) Status() int
- func (s *StatusRecorder) Unwrap() http.ResponseWriter
- func (s *StatusRecorder) Write(b []byte) (int, error)
- func (s *StatusRecorder) WriteHeader(code int)
- func (s *StatusRecorder) Wrote() bool
Examples ¶
Constants ¶
const HeaderRequestID = "X-Request-ID"
HeaderRequestID is the canonical request-id header. RequestLogger reads it from the inbound request and echoes it on the response.
const MaxJSONBody int64 = 1 << 20
MaxJSONBody is the default maximum JSON request-body size (1 MiB) applied by DecodeBody and DecodeBodyOptional.
Variables ¶
var ErrShutdownGraceExpired = errors.New("webhttp: shutdown grace period expired")
ErrShutdownGraceExpired marks a Run return whose origin was the shutdown grace period running out, so a caller can tell WHICH DeadlineExceeded it is holding.
Run's error is one value with two possible origins: a serve error carrying a deadline of the caller's own making, or the graceful sequence outliving the single shutdown grace. Both can satisfy errors.Is(err, context.DeadlineExceeded), and a caller that assumes the second is asserting something the value alone cannot prove. A grace-expiry return is wrapped so both hold:
errors.Is(err, webhttp.ErrShutdownGraceExpired) // the origin: the grace ran out errors.Is(err, context.DeadlineExceeded) // still true, unchanged
The diagnosis itself stays the caller's: naming the grace constant to raise, deciding the log level, and choosing the exit code are app policy.
var ErrTrailingData = errors.New("webhttp: unexpected data after JSON value")
ErrTrailingData is returned by DecodeJSONInto when the body holds more than a single JSON value (data follows the first decoded value). Callers that map decode errors to a status treat it like any other malformed-body error (400).
Functions ¶
func AwaitDone ¶ added in v1.20.0
AwaitDone blocks until done is closed or ctx expires, and reports whether done closed in time. It is the bounded WAIT a teardown needs, not the teardown itself: shutdown teardown bodies stay app-owned, and so does every policy around the result — what to log, at which level, whether to name the grace constant, whether to count it.
It deliberately creates NO timeout of its own and logs nothing. Run hands onShutdown a context carrying whatever remains of the ONE shutdown grace after the pre-drain phase and the HTTP drain have spent their share, so a fresh deadline invented here would hand the teardown a budget the shutdown sequence does not actually have.
The post-expiry recheck is the reason this is worth sharing. A drain that consumed the whole grace hands the teardown an ALREADY-EXPIRED context, and a select whose cases are both ready picks pseudo-randomly — so the naive two-case select reports a goroutine that DID finish as still running a fraction of the time, turning a clean drain into a spurious warning. After ctx fires, AwaitDone re-checks done and only then reports false.
A nil done channel never becomes ready, so the wait is then bounded by ctx alone.
func CanonicalHost ¶ added in v1.11.0
CanonicalHost parses an HTTP Host header value (or a configured allowlist entry) into its canonical host for exact comparison, returning "" when the value is not a well-formed authority. Accepted shapes, per the RFC 3986 authority grammar:
- a DNS name of ASCII labels (letters, digits, hyphens, and underscores — Docker Compose service names carry underscores — 63 bytes max per label), optionally ending in one FQDN dot, optionally followed by one ":port" (digits, at most 65535)
- an IPv4 literal, with the same optional trailing dot and :port
- a bare IPv6 literal (no port: an unbracketed colon-bearing value can only be an address, never host:port)
- a bracketed IPv6 literal "[...]", optionally followed by ":port"
The canonical form is lowercase with no port, no brackets, and no trailing dot; IP literals are rendered through net.ParseIP so textually different spellings of one address (::1 vs 0:0:0:0:0:0:0:1, [::ffff:192.0.2.1] vs 192.0.2.1) compare equal. The function is idempotent: CanonicalHost(CanonicalHost(x)) == CanonicalHost(x).
Everything else returns "": stray or unmatched brackets, a bracketed non-IPv6 ("[allowed.example]" — brackets are IPv6-only syntax), a non-numeric, out-of-range, or empty port, a second port, more than one trailing dot, an empty label, a non-ASCII name (configure the Punycode A-label, "xn--...", instead — matching is byte-exact and no IDN mapping is performed), and an all-numeric dotted name that net.ParseIP rejects ("127.0.0.001", "0177.0.0.1" — HTTP clients disagree on leading-zero and octal IPv4 forms, so no single textual key can match them safely).
The sibling ssrf library carries its own numeric-IPv4 heuristic (looksLikeNumericIPv4) with a deliberately DIFFERENT reject set: ssrf feeds a resolver, so it must also reject dotted-hex forms like "0x7f.0.0.1" that this exact-match key safely accepts (the "x" makes the label a plain DNS label, matchable only if explicitly allowlisted). The two must NOT be unified — see that function's doc for the outbound rationale.
Malformed input is REJECTED, never repaired. Deleting the offending syntax instead (stripping stray brackets, truncating at a bad port) would let distinct wire values collapse onto an allowlisted key — "[allowed.example]", "allowed[.]example", and "allowed.example:garbage:443" would all admit as "allowed.example" — silently widening an exact-match allowlist.
No name resolution is performed. Resolving a hostname to compare against the request would reopen the very DNS-rebinding race a host allowlist exists to close (the attacker controls what the name resolves to), so matching is purely textual on the canonicalized Host.
func CausedByCancellation ¶ added in v1.20.0
CausedByCancellation reports whether err is the observable form of THIS context's cancellation, so a boundary can tell a routine stop apart from a fault that merely happened at the same moment.
It PROVES the match rather than assuming it. A cancelled context alone is not evidence: a listener bind that genuinely failed while a signal was arriving must still read as a bind failure, not as a clean stop — the classification mistake this predicate exists to prevent. So err must carry either the stable ctx.Err() or the cancellation cause.
Matching context.Cause(ctx) as well as ctx.Err() is what makes it usable at a net/http boundary: a cause passed to context.WithCancelCause need not wrap context.Canceled, and net/http reports a cancelled operation as the cause verbatim, so a ctx.Err()-only check misses it. A nil ctx, a nil err, or a context that is not cancelled all report false.
What to DO with a true answer stays the caller's policy — the log level, the message, the exit code, and whether the operation is retried are none of the library's business.
func Chain ¶
func Chain(h http.Handler, mw ...Middleware) http.Handler
Chain wraps h with the given middlewares and returns the composed handler. The FIRST middleware listed becomes the OUTERMOST wrapper: it is the first to see the request on the way in and the last to touch the response on the way out. The LAST middleware listed sits closest to h.
So Chain(h, A, B, C) is equivalent to A(B(C(h))): a request flows A -> B -> C -> h and the response unwinds h -> C -> B -> A. List middlewares in the order you want them to execute; a typical stack puts logging outermost so it observes the final status, then a panic recoverer, then app concerns:
handler := webhttp.Chain(mux, webhttp.Logging(), // outermost: logs every request webhttp.Recoverer(), // catches panics from everything below it webhttp.SecurityHeaders(), // sets headers before the app runs )
A nil middleware in the list is skipped, so callers can include entries conditionally.
func ClientIP ¶
ClientIP returns the best-effort client IP for r.
The spoofing model is the point of this helper. X-Forwarded-For is set by clients and intermediaries and is trivially forgeable, so it is consulted ONLY when the immediate TCP peer (the host part of r.RemoteAddr) is a proxy you control, i.e. it falls inside one of the caller-supplied trusted ranges:
- With NO trusted ranges, or when the direct peer is not inside one, the forwarded header is ignored entirely and the peer address (which cannot be spoofed at this layer) is returned. This is the safe default: no header a client sends can move the result off the real socket peer.
- When the peer IS a trusted proxy, X-Forwarded-For is walked from the RIGHT. Each entry that is itself a trusted proxy is skipped (those are your own hops, which appended the address they saw); the first untrusted entry from the right is returned as the client. A malformed entry at that boundary stops the walk, and the peer is returned.
The right-to-left, skip-trusted walk is the only correct reading when the proxy APPENDS the peer it observed to X-Forwarded-For (as Caddy and most reverse proxies do): the LEFTMOST entry is then whatever the client SENT and is attacker-controlled, while the rightmost entries are the trustworthy hops. Consequently the trusted set must contain EVERY proxy hop between the client and this server; if a hop is missing from the set the walk stops there and that hop's address is returned as the client.
X-Real-IP is deliberately NOT consulted: it is client-settable and a proxy such as Caddy does not overwrite it, so honoring it would reintroduce a spoof vector. It can return as an explicit opt-in if a proxy that overwrites it is ever adopted.
The caller supplies the trusted CIDRs (typically the reverse proxy's address range); the library hardcodes none. A malformed r.RemoteAddr with no port is used verbatim as the host and, being unparseable, is never trusted.
func DecodeBody ¶
DecodeBody limits the body to MaxJSONBody and decodes exactly one JSON value into v. The decoded value must be the entire body: trailing data or a second JSON value is rejected. On any decode failure it writes a 400 error response (code "bad_request") carrying errMsg and returns false; on success it returns true. It is DecodeJSONInto at the default cap with a coded-400 response — see DecodeJSONInto for the mechanism and the app-taxonomy escape hatch.
func DecodeBodyOptional ¶
func DecodeBodyOptional(w http.ResponseWriter, r *http.Request, v any)
DecodeBodyOptional limits the body to MaxJSONBody and attempts a JSON decode into v, ignoring any error. Use it when the body is optional and a missing or malformed body should leave v at its zero value rather than fail the request.
func DecodeJSONInto ¶ added in v1.2.0
DecodeJSONInto caps the request body at maxBytes (via LimitBody) and decodes exactly one JSON value into v, rejecting trailing data (a second value or any bytes past the first are an error). It returns the decode error and writes NOTHING to w — so callers that layer their own error taxonomy or need a non-default cap can map the result themselves. The returned error is:
- a *http.MaxBytesError (test with errors.As) when the body exceeded maxBytes — the caller decides 413 vs 400;
- ErrTrailingData when a second JSON value follows the first;
- otherwise the underlying *json.SyntaxError / *json.UnmarshalTypeError / io.EOF (empty body) for a malformed body — typically a 400.
It is the shared mechanism behind DecodeBody: DecodeBody is DecodeJSONInto at the MaxJSONBody cap plus a coded-400 WriteError on any error. Apps with a bare {"error":…} envelope, a per-endpoint size cap, or a 413/400 split (see the webhttp consumers) build their own one-liner on it instead of reproducing the cap + decode + trailing-check by hand.
func InlineScriptHashes ¶ added in v1.8.0
InlineScriptHashes scans HTML for inline <script> elements (those WITHOUT a src attribute) and returns a CSP source token 'sha256-<base64>' for each, hashing the exact bytes between the element's '>' and its '</script>' — precisely the content a browser hashes for a Content-Security-Policy script-src hash. External (src=) scripts are skipped; 'self' already covers them.
It exists so an app serving a build-controlled embedded page (an importmap plus a module bootstrap are the classic pair) can pin its script-src to exact hashes instead of 'unsafe-inline', computing the tokens at startup from the very bytes it will serve — a policy that then survives any reformat or rebuild with no hand-maintained hash constant. Feed the result into the app's policy string and pass that via WithCSP; the library builds no policy itself (a CSP is application-specific).
The scanner is byte-precise and dependency-free: case-insensitive tag matching, quote-aware attribute scanning (a '>' or "src=" inside a quoted attribute value does not confuse it), and "src" matched only as a real attribute name (srcset and data-src do not count). It is an extractor for pages the APP controls, not an HTML sanitizer for untrusted input. It returns an empty slice on script-less or malformed input; a caller whose page is known to carry inline scripts should treat an empty result as a malformed build and fail startup rather than degrade its policy.
func InlineStyleHashes ¶ added in v1.16.0
InlineStyleHashes scans HTML for inline <style> elements and returns a CSP source token 'sha256-<base64>' for each, hashing the exact bytes between the element's '>' and its '</style>' — precisely the content a browser hashes for a Content-Security-Policy style-src hash.
It is the style-src counterpart of InlineScriptHashes and shares its scanner core, so the byte-boundary and malformed-tag behavior cannot drift between the two. It exists for the same reason: an app serving a build-controlled embedded page can pin style-src to exact hashes instead of 'unsafe-inline', computed at startup from the very bytes it will serve. A pre-JS loading overlay whose CSS must paint before the external stylesheet loads is the classic case — and one a hash suits well, because that block is build-controlled.
There is no skip rule, unlike the script scanner's external-src case: a <style> element always carries its content inline (a stylesheet reference is <link rel="stylesheet">, which style-src covers via 'self'). A media attribute does not change what a browser hashes, so such blocks are hashed like any other.
Note what a style-src hash does NOT cover: inline style ATTRIBUTES (style="..."), which are governed by style-src-attr and need 'unsafe-hashes' rather than an element hash. This function is for <style> ELEMENTS only, so an app whose markup or renderer sets style attributes cannot drop 'unsafe-inline' on the strength of these tokens alone. A renderer driving CSSOM property setters (element.style.color = …) emits no attribute and is unaffected.
Same posture as the script scanner: an extractor for pages the APP controls, not an HTML sanitizer for untrusted input. It returns an empty slice on style-less or malformed input; a caller whose page is known to carry an inline style block should treat an empty result as a malformed build and fail startup rather than degrade its policy.
func JSONHeaders ¶
func JSONHeaders(w http.ResponseWriter)
JSONHeaders sets the standard JSON response headers: an application/json content type and the X-Content-Type-Options: nosniff guard against MIME sniffing. Call it before WriteHeader.
The nosniff guard is set only when the header is still unset, so a stack that composes SecurityHeaders keeps that middleware as the header's single writer while a stack without it still gets the guard on every JSON body this library writes.
func LimitBody ¶
func LimitBody(w http.ResponseWriter, r *http.Request, maxBytes int64)
LimitBody caps the request body at maxBytes by replacing r.Body with an http.MaxBytesReader. Call it before reading the body.
It writes NOTHING to w, so detecting and answering an over-limit body is the CALLER's job, on the read error — that error is the ONLY signal the condition reliably delivers:
webhttp.LimitBody(w, r, maxBytes)
body, err := io.ReadAll(r.Body)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
// tooLarge.Limit == maxBytes. The status is yours: 413, 400, or none
// at all (log it and drop the request).
}
Every read path in the package keeps that contract: DecodeJSONInto returns the *http.MaxBytesError untouched, and DecodeBody maps it — like any other decode failure — to a 400.
The connection-close signal, and when it is absent ¶
http.MaxBytesReader also tells net/http that the request was too large, which makes the server add Connection: close and close the connection after the reply rather than draining the sender's remaining bytes. It delivers that by type-asserting an UNEXPORTED net/http interface on the ResponseWriter it was handed, so it reaches net/http ONLY when the writer is net/http's own — no third-party wrapper can satisfy an unexported method, and MaxBytesReader does not walk Unwrap itself. LimitBody therefore walks w's Unwrap chain (the http.ResponseController convention) and hands MaxBytesReader the writer at the end of it. The middlewares here that wrap the writer to record a status (Logging and Recoverer, via StatusRecorder) implement Unwrap, so a normal Chain keeps the signal.
It is still absent, with no way for this package to recover it, when:
- some wrapper in the chain does not implement Unwrap() http.ResponseWriter (a third-party middleware): the walk stops at that wrapper;
- the handler runs under RouteTimeout / http.TimeoutHandler, whose buffering writer is not unwrappable;
- w is not a net/http writer at all (an httptest.ResponseRecorder).
In every one of those cases the read still fails with a *http.MaxBytesError — which is why the caller-side check above is the contract and the close is not. Independently of this signal, net/http closes the connection anyway when more than 256 KiB of the body is left unread.
func MethodNotAllowed ¶ added in v1.18.0
func MethodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string)
MethodNotAllowed writes the 405 refusal for a request whose method the route does not support: it sets the Allow header to the allowed set (see SetAllow) and writes the standard error response (code "method_not_allowed"). It is the rejection half of RequireMethod, exported because a route may permit SEVERAL methods and so cannot express its refusal as a single-method guard:
// POST /things and GET /things are registered; HEAD is not served.
mux.HandleFunc("HEAD /things", func(w http.ResponseWriter, r *http.Request) {
webhttp.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
})
A handler that dispatches on the method itself (a switch over r.Method) calls it from the default branch with the same list. Either way the 405 carries the full Allow list RFC 9110 requires, instead of the one method a guard could name.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/cplieger/webhttp"
)
func main() {
rr := httptest.NewRecorder()
// A route serving GET and POST refuses everything else, and RFC 9110
// requires the 405 to advertise BOTH permitted methods, not just one.
webhttp.MethodNotAllowed(rr, nil, http.MethodGet, http.MethodPost)
fmt.Println(rr.Code)
fmt.Println(rr.Header().Get("Allow"))
fmt.Print(rr.Body.String())
}
Output: 405 GET, POST {"error":"method not allowed","code":"method_not_allowed"}
func NewRequestID ¶
func NewRequestID() string
NewRequestID returns a fresh request id: 16 cryptographically random bytes, hex-encoded to 32 characters. crypto/rand.Read never returns an error (since Go 1.24 it crashes the program irrecoverably if the platform random source fails), so id generation cannot degrade.
func NewServer ¶
func NewServer(handler http.Handler, opts ...ServerOption) *http.Server
NewServer builds an *http.Server for handler with streaming-safe defaults: ReadHeaderTimeout 10s (a slowloris guard), IdleTimeout 120s, MaxHeaderBytes 1 MiB, and ReadTimeout/WriteTimeout left unset (0) so SSE, WebSocket, and other long-lived responses work out of the box. Options override the defaults.
Because ReadTimeout and WriteTimeout are unset by default, only header reading is time-bounded (by ReadHeaderTimeout); a slow request BODY is not. A non-streaming handler should add WithReadTimeout to bound slowloris-style slow bodies. A streaming handler, which cannot use a whole-request timeout, should instead apply per-request deadlines via http.ResponseController.SetReadDeadline/SetWriteDeadline. Note that MaxBytesReader (see LimitBody) bounds body SIZE, not the time taken to send it.
func Ok ¶
func Ok(w http.ResponseWriter)
Ok writes a 200 response with the JSON body {"ok":true}. It is the canonical success acknowledgement for an endpoint that has no other payload.
Example ¶
package main
import (
"fmt"
"net/http/httptest"
"github.com/cplieger/webhttp"
)
func main() {
rr := httptest.NewRecorder()
webhttp.Ok(rr)
fmt.Print(rr.Body.String())
}
Output: {"ok":true}
func ParseCIDRs ¶ added in v1.2.0
ParseCIDRs parses trusted reverse-proxy entries into the *net.IPNet set that ClientIP and WithClientIP consult. Each entry is either CIDR notation ("10.0.0.0/8") or a bare IP address ("192.168.1.5", "::1"), a bare address being treated as a single host (/32 for IPv4, /128 for IPv6). Surrounding whitespace is trimmed and blank entries are skipped.
It returns the successfully parsed networks and, separately, the list of entries that were neither a valid CIDR nor a valid IP. Returning both (rather than failing on the first bad entry) lets a strict caller reject the input — e.g. config-file validation surfacing the offending entry — while a lenient caller logs the bad entries and proceeds with the valid subset, e.g. reading a TRUSTED_PROXIES environment variable where one typo should not disable proxy awareness entirely. Both usages share this one parser instead of each app reimplementing the CIDR/bare-IP handling. The returned slice is passed straight to ClientIP(r, nets...) or WithClientIP(nets...); an empty result means "trust nothing", the spoof-proof default that logs the socket peer.
func ReadinessHandler ¶
func ReadinessHandler(c ReadinessChecker) http.HandlerFunc
ReadinessHandler returns a handler that reports serving state as JSON: 200 with {"status":"ok"} when c reports ready, otherwise 503 with {"status":"unready","reason":"starting up or shutting down"}. Both responses carry Cache-Control: no-store.
This is the HTTP SERVING-STATE gate (note the lowercase "ok"), meant for a load balancer asking "should this instance receive traffic right now?". It is deliberately distinct from the cplieger health library's container file-marker probe, which answers {"status":"OK","timestamp":…} for a Docker HEALTHCHECK asking "is the process alive?". The two are complementary and are not the same endpoint.
no-store is not decoration. Under RFC 9111 a 200 GET carrying no explicit freshness information is HEURISTICALLY CACHEABLE, and the answer to "should this instance receive traffic right now" is the one answer in a service that is never valid a moment later. The unready direction is safe by accident (503 is not among the heuristically-cacheable statuses), so the reachable failure is the dangerous one: a cached "ok" outliving the readiness it reported, which keeps traffic arriving at an instance that has begun draining — defeating the gate at exactly the moment it exists for. Consumers that front this endpoint with a caching reverse proxy (the documented deployment shape for more than one of them) were relying on that proxy not to cache.
func RequestIDFromContext ¶
RequestIDFromContext returns the request id stored in ctx by WithRequestID, or "" if none is present.
func RequestLogger ¶
RequestLogger returns middleware that gives each request a request id, echoes it on the response HeaderRequestID header, threads it through the request context (see RequestIDFromContext), and emits one access-log line at Info after next returns:
logger.Info("http", "method", …, "path", …, "status", …,
"duration_ms", …, "request_id", …)
With WithClientIP the line additionally carries a "client_ip" attribute resolved by ClientIP (spoof-proof, honoring only the trusted proxy ranges passed to the option); WithClientIPFunc is the variant that resolves it with a caller-supplied function, for a dynamic (e.g. config-reloaded) trusted set. With WithPathFunc the recorded path is fn's return instead of r.URL.Path — the token-redaction middle ground between logging a credential-bearing path raw and skipping its access record entirely (fail-closed placeholder when the transform fails; see the option).
The two attacker-controlled attributes are bounded by default, whichever policy produced them: the path to 512 bytes cut on a rune boundary plus a truncation marker (WithMaxLoggedPath re-sets the cap), and the method to 24 bytes, over which it records a fixed "(overlong)" placeholder rather than a misleading prefix. Both bounds apply to the LOG only — routing, the status, and the response are unchanged.
It records the status via a StatusRecorder that stays transparent to http.ResponseController, so wrapped handlers can still Flush and Hijack. An inbound HeaderRequestID is reused when it satisfies ValidRequestID; otherwise a new id is minted with NewRequestID.
A request matched by WithSkipPaths or WithSkipFunc still gets an id minted, echoed, and threaded, but is served through the raw writer with no recorder, no access-log line, and no metric hook. WithSkipUpgrades removes the record of a request that actually SWITCHED PROTOCOLS (a completed WebSocket handshake), decided from the response instead of predicted from the request, so every refusal on the same route keeps its line; it can only remove a record, never restore one a skip rule dropped.
The access-log line and metric hook are emitted from a deferred call, so a handler that panics is still logged (the status shows the recorded value) before the panic continues up the stack to net/http.
func RequireMethod ¶
RequireMethod reports whether the request method equals method. On a mismatch it writes the standard 405 via MethodNotAllowed, naming method as the only allowed one, and returns false, so a handler can guard with:
if !webhttp.RequireMethod(w, r, http.MethodPost) {
return
}
A route that permits more than one method has no single method to require: route each method with the mux (net/http's method-prefixed patterns) or dispatch on r.Method, and refuse the rest with MethodNotAllowed so the 405 advertises the whole set.
func RouteMetricLabels ¶ added in v1.19.0
RouteMetricLabels derives the bounded (method, path) label pair for a per-request HTTP metric. It is the pure primitive behind WithRecordRouteMetric, exported for an app that already has a WithRecordMetricRequest hook for other reasons and wants the standard pair inside it — but the OPTION is the recommended path, because a helper only protects the consumers that remember to call it.
The method label is r.Method when it is one of the nine standard methods — GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE (RFC 9110 §9.3) and PATCH (RFC 5789) — and the fixed "other" bucket for everything else. Ten values, by construction, whatever arrives on the request line.
The path label is the route http.ServeMux matched: r.Pattern with the method prefix stripped ("GET /beat/{id}" records "/beat/{id}", so every beat id records as one series and an unknown id mints nothing), the pattern itself when it names no method ("/beat/{id}", a "/" catch-all), and the fixed "unmatched" marker when nothing matched (r.Pattern == "", e.g. net/http's own 404, or its 405 for a method that missed a method-bearing route). A host-qualified pattern ("GET example.com/beat/{id}") keeps its host — still a string the server registered, still bounded.
Every value on both labels is therefore either a pattern the SERVER registered or one of eleven fixed strings this package can return (the nine standard method names, the "other" bucket, the "unmatched" marker), so the series ceiling is ten times one more than the route table and no property of the traffic can widen it. That matters because the hook fires from the access-log defer, outside every app auth gate, and a series once minted is permanent for the process lifetime here AND in every observer scraping it.
The method comes from the REQUEST, bounded by the closed set, rather than from the matched pattern. Deriving it from the pattern is also bounded, but it makes http.ServeMux's HEAD-to-GET routing invisible: a HEAD probe against a GET-only pattern would record method="GET" while the access line for the same request records HEAD, so the log and the metric disagree about the commonest non-GET probe there is. Reading the closed set instead keeps them in agreement for all nine standard methods and still owes nothing to the route table.
ONE divergence from the access line remains, deliberately: for a NON-standard method the line records the token verbatim (bounded to 24 bytes, see boundLoggedMethod's marker) while this records "other". A log line is read to diagnose one request and needs the real value; a metric series is read in aggregate and needs a bounded name. When a metric shows "other" traffic, the access line for the same request_id says what the method was.
Caveat, inherited from the hooks: middleware between RequestLogger and the mux that replaces the request (r.WithContext and friends return a clone) leaves r.Pattern empty here, because the mux populated the clone. That reads as "unmatched" for every request — check it before believing a flat metric.
Example ¶
package main
import (
"fmt"
"net/http"
"github.com/cplieger/webhttp"
)
func main() {
// The labels for a request that matched "GET /beat/{id}". A HEAD probe
// records HEAD, not the pattern's GET, so the metric and the access line
// agree on the method.
head := &http.Request{Method: http.MethodHead, Pattern: "GET /beat/{id}"}
fmt.Println(webhttp.RouteMetricLabels(head))
// A non-standard method collapses onto one bucket, so a caller cannot mint a
// series per invented token. The access line still records the token itself.
probe := &http.Request{Method: "PROPFIND", Pattern: "/"}
fmt.Println(webhttp.RouteMetricLabels(probe))
// Nothing matched: every unrouted request shares one path label.
missed := &http.Request{Method: http.MethodGet}
fmt.Println(webhttp.RouteMetricLabels(missed))
}
Output: HEAD /beat/{id} other / GET unmatched
func RouteTimeout ¶
RouteTimeout wraps h with http.TimeoutHandler so a handler that runs longer than d is cut off with a 503, but replaces net/http's plain-text/HTML timeout body with a JSON ErrorResponse ({"error":msg,"code":"timeout"}) served as application/json. An empty msg defaults to "request timed out".
A non-positive d disables the timeout: h is returned unwrapped, so its response passes through untouched with no 503 relabeling. (http.TimeoutHandler with a zero or negative duration would otherwise fire the timeout immediately.)
The JSON relabeling keys on status alone: any 503 that reaches the client WITHOUT a Content-Type already set is served as application/json, because the wrapper cannot tell http.TimeoutHandler's own timeout envelope apart from a downstream handler's intentional 503. A handler that emits its own 503 must therefore set an explicit Content-Type, or it will be relabeled application/json (its body bytes are left unchanged; only the headers are added).
It CANNOT wrap streaming or hijacking handlers: http.TimeoutHandler buffers the entire response in memory to be able to discard it on timeout, so SSE, WebSocket upgrades, and other long-lived or flushing responses do not work through it. Apply RouteTimeout only to bounded request/response handlers, and use per-request deadlines (http.ResponseController.SetWriteDeadline) for streaming routes instead.
That buffering writer is also not unwrappable, so a handler under it cannot reach the ResponseWriter net/http gave the request: on a body-reading route, LimitBody's over-limit read still fails with a *http.MaxBytesError but net/http is never told to close the connection (see LimitBody).
The timeout envelope follows the package's universal request-id correlation scheme, exactly like WriteError: it is rendered per request, so when the request context carries an id (RouteTimeout composed under Logging / RequestLogger) the 503 body includes request_id, and when it does not the field is omitted. http.TimeoutHandler requires the body pre-rendered at construction, which is why the handler is assembled per request around the request-scoped envelope.
func Run ¶
func Run(ctx context.Context, srv *http.Server, ln net.Listener, onShutdown func(ctx context.Context), opts ...RunOption) error
Run serves srv on ln until ctx is cancelled, then shuts down gracefully.
It starts srv.Serve(ln) in a goroutine (treating http.ErrServerClosed as a clean stop) and blocks until either ctx is cancelled or Serve returns on its own. On cancellation it computes a single shutdown deadline (now + the shutdown grace period) and runs the shutdown sequence against it: first the WithPreDrain hook if one is registered (readiness flips, stream releases — see WithPreDrain), then srv.Shutdown with a context bounded by the deadline, then, if onShutdown is non-nil, onShutdown with a context bounded by that SAME deadline: each later phase runs within whatever grace budget REMAINS after the earlier ones, not a fresh full window. Run returns the first non-ErrServerClosed error it observes (a serve error, else a shutdown error), or nil on a clean graceful stop. A shutdown error that IS the grace period running out is additionally wrapped in ErrShutdownGraceExpired, so a caller can identify that origin instead of guessing it from a bare context.DeadlineExceeded; the wrapped error stays in the chain, so existing errors.Is checks are unaffected.
When Serve instead returns on its own, before ctx is cancelled, none of that sequence runs: there is no drain to bound and nothing has asked the application to stop. Run returns the serve error straight away, having invoked only the opt-in WithServeExit hook when one is registered — that hook is the sole way to get a bounded teardown on this path.
The caller binds ln up front (for example with net.ListenConfig.Listen) so a port-in-use error surfaces synchronously before Run is called, and passes application teardown as onShutdown.
func SetAllow ¶ added in v1.18.0
func SetAllow(w http.ResponseWriter, allowed ...string)
SetAllow sets the RFC 9110 Allow header to the set of methods a route advertises as supported: the entries joined with ", " (RFC 9110 Section 10.2.1, "Allow = #method"). A 405 response MUST carry the header, which is why MethodNotAllowed and RequireMethod both render it here; the spec permits it on any other response too, so an OPTIONS handler can advertise its route with the same call.
Entries are method tokens (http.MethodGet and friends) emitted verbatim, because a method token is case-sensitive (Section 9.1): the header must name what the route actually compares a request method against, not a canonicalized spelling of it. An empty entry is dropped, so a set assembled from configuration cannot emit the empty list element a sender must not generate (Section 5.6.1.1), and an exact duplicate is collapsed because the field names a set. Passing no methods sets the header to the empty value, which is the spec's own encoding for "this resource allows no methods" — a route disabled by configuration — and keeps the 405's MUST satisfied.
HEAD is deliberately NOT implied by GET. The field reports what THIS route advertises, and a route may refuse HEAD on purpose: net/http's ServeMux serves a HEAD request from a GET pattern, so a route whose GET carries a side effect (recording a heartbeat, say) registers HEAD separately to reject it and must not then advertise it. Pass http.MethodHead explicitly when the route serves it.
func StaticHandler ¶ added in v1.8.0
StaticHandler serves an embedded (or any fs.FS) static tree with the revalidation and compression plumbing embed.FS is missing.
embed.FS reports a zero ModTime, so a bare http.FileServer emits neither Last-Modified nor ETag, leaving the browser no way to revalidate: every full page load re-downloads every asset. The bytes of an embedded tree are fixed for the process lifetime, so StaticHandler walks the tree ONCE at construction and precomputes, per file:
- a content-hash ETag (sha256), so http.ServeContent answers a matching If-None-Match with 304 Not Modified;
- a gzip representation (best compression), kept only when it is actually smaller — already-compressed formats (woff2, png) and tiny files stay identity-only.
Responses for known assets carry the ETag, a Cache-Control value from the cache policy (default "no-cache"; see WithStaticCacheControl), and Vary: Accept-Encoding (bodies vary by encoding, so shared caches must key on it on every path). A client that offers gzip on a non-Range GET/HEAD gets the precompressed body with Content-Encoding: gzip and a DISTINCT ETag (the identity tag suffixed -gz), including its own 304 handling; everything else falls through to the identity http.FileServer, which serves the index.html for "/" and handles Range, directories, and 404s.
The error is non-nil only when walking fsys fails (an unreadable embedded tree is a malformed build; fail startup rather than serve a partial site). Serving is allocation-free per request apart from net/http's own work: all hashing and compression happened at construction.
func ValidRequestID ¶
ValidRequestID reports whether s is a well-formed request id: between 1 and 64 characters, each an ASCII letter, digit, underscore, or hyphen. Anything else (empty, too long, or containing another byte) is rejected, so a client cannot smuggle log-forging newlines or header-splitting content through the echoed id.
Example ¶
package main
import (
"fmt"
"github.com/cplieger/webhttp"
)
func main() {
fmt.Println(webhttp.ValidRequestID("abc-123_XYZ"))
fmt.Println(webhttp.ValidRequestID("bad id"))
}
Output: true false
func WithRequestID ¶
WithRequestID returns a copy of ctx carrying the request id.
func WriteError ¶
WriteError writes an ErrorResponse with the given HTTP status: msg becomes Error, code becomes Code, and the request id is pulled from the request context (via RequestIDFromContext) so a client can correlate the failure with the access log. It is nil-safe: when r is nil the RequestID field stays empty.
WriteError ships the MECHANISM only. Each consuming application keeps its own named-helper and error-code taxonomy on top of it.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/cplieger/webhttp"
)
func main() {
rr := httptest.NewRecorder()
// r may be nil; the request_id field is then omitted.
webhttp.WriteError(rr, nil, http.StatusBadRequest, "bad_request", "invalid payload")
fmt.Print(rr.Body.String())
}
Output: {"error":"invalid payload","code":"bad_request"}
func WriteJSON ¶
func WriteJSON(w http.ResponseWriter, v any)
WriteJSON writes v as a JSON body with a 200 status.
func WriteJSONStatus ¶
func WriteJSONStatus(w http.ResponseWriter, code int, v any)
WriteJSONStatus sets the JSON headers, writes the status code, and encodes v as the response body. The status is committed before encoding begins, so an encode failure cannot change it; such a failure is logged at Warn rather than returned, because the response line is already on the wire.
Types ¶
type BindClass ¶ added in v1.12.0
type BindClass int
BindClass is the network-exposure classification of a configured listen address — the value an operator puts in a bind knob ("KWEB_ADDR", "WT_ADDR", "LISTEN_ADDR") before the process calls net.Listen. Apps use it at boot to drive exposure POLICY (warn that an unauthenticated surface is reachable beyond loopback, flag an open-and-public deployment); the classification itself is the shared mechanism.
Classify the CONFIGURED bind only. A live socket peer (r.RemoteAddr) is a different concern with different inputs (the Go server always supplies a parseable ip:port, and the question is "who connected", not "where do I listen"); peer decisions belong to their own gates (e.g. HostPolicy's loopback carve-out), never to this classifier.
const ( // BindInvalid means the address does not parse as "host:port" // (net.SplitHostPort rejected it: no port, stray brackets, too many // colons). The zero value, so an uninitialized classification never // reads as a safe loopback bind. What to do with it is app policy — // see the recipes on ClassifyBind. BindInvalid BindClass = iota // BindLoopback means the host names the loopback interface explicitly: // a 127.0.0.0/8 or ::1 literal (including 4-in-6 forms of 127/8), or // the name "localhost", matched case-insensitively via // strings.EqualFold (Unicode simple folding, so exotic folds like // "localhoſt" match too — the two origin apps that folded already // behaved this way). Only these are "safe" in the exposure sense: an // IP-literal loopback bind is unreachable from other machines by // kernel routing, and a "localhost" bind is loopback-only under the // standard resolver mapping (RFC 6761 §6.3 — a SHOULD an operator's // hosts file can override; the classifier reads the text, not the // resolver). BindLoopback // BindExposed means the listener is (or may be) reachable beyond // loopback: a wildcard bind (empty host, "0.0.0.0", "::"), any // routable IP literal, or any hostname other than "localhost". A // hostname is classified WITHOUT resolution — fail-public: the // classifier cannot know what "myhost" resolves to at bind time, and // resolving here would race the bind itself. A zoned IPv6 literal // ("::1%lo") also lands here via the hostname path (net.ParseIP // rejects zones); binding one is exotic enough that the fail-public // reading is the safe one. BindExposed )
func ClassifyBind ¶ added in v1.12.0
ClassifyBind classifies a configured listen address of the "host:port" form net.Listen accepts. The HOST decides the class; the port is not validated (net.Listen owns that failure, with a better error than any pre-check here could give).
An address net.SplitHostPort cannot split returns BindInvalid, and what that means is deliberately the caller's policy. The three shipped policies, as recipes:
// Fail-silent (web-terminal-kiro): an unparseable addr will fail at
// Listen anyway; only a definite exposure warrants the warning.
if webhttp.ClassifyBind(addr) == webhttp.BindExposed { warn(...) }
// Fail-public (pg-autodump): a spurious warning is preferable to a
// silently unflagged open endpoint.
if webhttp.ClassifyBind(addr) != webhttp.BindLoopback { warn(...) }
// Classify-the-unsplit-input (web-terminal-server): a portless value
// like "127.0.0.1" is read as a bare host and classified anyway.
class := webhttp.ClassifyBind(addr)
if class == webhttp.BindInvalid { class = webhttp.ClassifyBindHost(addr) }
if class != webhttp.BindLoopback { warn(...) }
No name resolution is performed and no repair is attempted; like CanonicalHost, the classifier reports what the input IS rather than guessing what the operator meant.
func ClassifyBindHost ¶ added in v1.12.0
ClassifyBindHost classifies a bare bind host (no port): the host half of a listen address, or a whole portless bind value under the classify-the-unsplit-input recipe above. It never returns BindInvalid — a bare host always classifies: "localhost" (any case) and loopback IP literals are BindLoopback; everything else, including the empty string (the wildcard "listen on all interfaces" host), unspecified addresses ("0.0.0.0", "::"), routable IPs, and unresolved hostnames, is BindExposed.
type ErrorResponder ¶ added in v1.4.0
ErrorResponder renders an error response body. It has the exact signature of WriteError, which is its canonical instance and the library-wide default, so the JSON envelope is the zero-config behavior. Middleware that emits an error body (currently Recoverer, via WithRecoverResponder) accepts one so a non-JSON endpoint - an XML or plain-text service - can keep its error body on its own content type instead of the default JSON. A responder owns writing the status and any headers, and is invoked only when the response has not been committed.
type ErrorResponse ¶
type ErrorResponse struct {
// Error is the human-readable error message.
Error string `json:"error"`
// Code is an optional machine-readable error code.
Code string `json:"code,omitempty"`
// RequestID is the request id for log correlation, when available.
RequestID string `json:"request_id,omitempty"`
}
ErrorResponse is the JSON error envelope written by WriteError. Code and RequestID are omitted from the output when empty.
type HostAllowlistOption ¶ added in v1.11.0
type HostAllowlistOption func(*hostPolicyConfig)
HostAllowlistOption configures a HostPolicy built by ParseHostList.
func WithHostAllowlistError ¶ added in v1.11.0
func WithHostAllowlistError(code, msg string) HostAllowlistOption
WithHostAllowlistError sets the error code and message written in the 403 JSON envelope (via WriteError) when a request's Host is not allowed. Defaults to "host_not_allowed" / "host not allowed". Override it to name the app's own configuration knob, e.g. "host not allowed; add it to KWEB_ALLOWED_HOSTS".
func WithLoopbackExempt ¶ added in v1.11.0
func WithLoopbackExempt() HostAllowlistOption
WithLoopbackExempt admits a request whenever BOTH its socket peer and its Host are loopback, regardless of the allowlist — the container-internal carve-out. It keeps a service's own loopback clients working under any allowlist: a baked Docker healthcheck (curl http://127.0.0.1:PORT), an in-container agent hitting localhost, a sidecar probe. Without it, an allowlist of browser-facing hostnames rejects those callers and can brick a health signal.
The carve-out is unreachable by the attacks a host allowlist defends against. A DNS-rebinding request carries the ATTACKER'S hostname in Host, so it fails the loopback-Host test even when it originates from a browser on this same machine (a loopback peer); and a remote client forging Host: 127.0.0.1 is not a loopback socket peer, so it fails the peer test. Both conditions must hold, and only genuinely local traffic can satisfy both.
Off by default: a bare allowlist rejects everything not listed, loopback included. Opt in when the service runs behind a loopback health check or serves in-container clients.
type HostPolicy ¶ added in v1.11.0
type HostPolicy struct {
// contains filtered or unexported fields
}
HostPolicy is an immutable, canonicalized exact-match Host allowlist. Build one with ParseHostList and apply it with Middleware; the same policy answers the per-request decision through Allows. The zero value and a nil pointer are both inactive (permissive), so a policy parsed from unset configuration is a safe pass-through.
func ParseHostList ¶ added in v1.11.0
func ParseHostList(entries []string, opts ...HostAllowlistOption) (policy *HostPolicy, invalid []string)
ParseHostList parses operator-supplied allowlist entries (a config array or a comma-split environment variable) into a HostPolicy, returning the entries it could not use separately — a shape deliberately identical to ParseCIDRs so a strict caller can reject bad input while a lenient caller logs it and proceeds.
Each entry is trimmed; a blank entry is skipped and does not engage the gate. A usable entry is canonicalized via CanonicalHost and added to the allowlist. An entry that is not a well-formed host[:port] under CanonicalHost's strict grammar is reported as invalid (returned, never added): a pasted URL ("http://example.com"), a lone port (":9848") that belongs in the bind address, bracket or colon garbage, a non-ASCII name (configure the Punycode A-label instead), or an IP-like numeric string that is not a valid address ("127.0.0.001").
Activation, and the fail-closed contract: the policy is INACTIVE only when no non-blank entry was supplied at all (unset or all-whitespace configuration) — then Middleware is a pass-through and Allows returns true, the backward-compatible "accept every Host" default. As soon as ANY non-blank entry is supplied the gate is ACTIVE, even if every entry was invalid: a misconfigured allowlist (all entries malformed) then rejects every request rather than silently disabling the protection. Because invalid entries are returned rather than retained as unmatchable keys, a caller that wants to fail loud can inspect the invalid slice at startup and refuse to boot; a caller that wants to proceed logs it and runs with the valid subset (or, when the subset is empty, a fail-closed deny-all).
Pair it with WithLoopbackExempt for a containerized service whose own loopback health check or in-container clients must keep working, and with WithHostAllowlistError to name the app's configuration knob in the 403.
func (*HostPolicy) Active ¶ added in v1.11.0
func (p *HostPolicy) Active() bool
Active reports whether the gate is engaged. A policy parsed from unset or all-blank configuration is inactive: Middleware is a pass-through and Allows returns true for every request. A nil policy is inactive.
func (*HostPolicy) Allows ¶ added in v1.11.0
func (p *HostPolicy) Allows(r *http.Request) bool
Allows reports whether the request is admitted. An inactive (or nil) policy admits every request. An active policy admits a request only when its canonicalized Host is in the allowlist, or — when WithLoopbackExempt was set — when both the socket peer and the Host are loopback. r.Host is the only host value consulted; X-Forwarded-Host is deliberately ignored (it is client-controlled and this check must hold on the direct-exposure path). A malformed Host canonicalizes to "" and can never match.
func (*HostPolicy) Middleware ¶ added in v1.11.0
func (p *HostPolicy) Middleware() Middleware
Middleware returns the allowlist as webhttp middleware. An inactive (or nil) policy returns the next handler unwrapped — the "off" contract shared with RateLimiter and RouteTimeout, so a policy from unset configuration adds no overhead. An active policy rejects a request whose Host is not allowed with a 403 via WriteError (code and message set by WithHostAllowlistError), before the next handler runs.
Placement: put it OUTERMOST among request-authorization middleware, before a cross-origin or CSRF check, so a disallowed host is rejected before any route runs — a DNS-rebinding request makes Origin and Host agree, so a same-origin check alone would admit it; the exact-Host allowlist is what breaks that chain (CWE-346).
func (*HostPolicy) Size ¶ added in v1.11.0
func (p *HostPolicy) Size() int
Size reports the number of valid, canonicalized hosts in the allowlist (excluding entries ParseHostList reported as invalid). A nil policy has size zero.
type LogOption ¶
type LogOption func(*logConfig)
LogOption configures RequestLogger.
func ProbeLogLevel ¶ added in v1.15.0
ProbeLogLevel is the fleet-standard access-log level policy for routine machine-probe endpoints — health checks, readiness probes, metrics scrapes (Docker HEALTHCHECK curls, Gatus monitors, Prometheus). A request whose r.URL.Path exactly matches one of paths logs at Debug when it succeeds (status < 400), Warn on a 4xx, and Error on a 5xx; every other request stays at the default Info.
The point: a probe hitting a HEALTHY endpoint every 30 seconds is noise and stays out of the shipped log stream (Debug is dropped below the operating level — but becomes visible the moment an operator raises the level to debug, when "is the probe even reaching me, from where" is the question), while a FAILING probe — the readiness 503, the broken-install signal — is exactly what an operator greps for and surfaces at Warn/Error with its status, duration, and request id. Prefer this preset over skipping probe paths entirely (a skip hides the failure too) and over leaving them at Info (a line every 30s per prober). Skip lists remain the right tool for STREAMS (SSE, WebSocket), where one open-to-close line is misleading by shape, not merely noisy.
It is a WithLogLevel policy under the hood, so the two are mutually exclusive (last applied wins), it composes with the skip options (a skipped path emits no line and never consults the policy), and a panicking policy falls back to Info per the WithLogLevel contract. With no paths every request logs at Info, as without the option.
func WithClientIP ¶ added in v1.2.0
WithClientIP adds a "client_ip" attribute to the access-log line, set to the best-effort client IP resolved by ClientIP with the given trusted proxy ranges. With no trusted ranges the immediate socket peer is logged (the spoof-proof default); pass the reverse-proxy CIDRs to resolve the real client from a trusted X-Forwarded-For, exactly as ClientIP does. The attribute is omitted entirely unless this option is supplied, so the default access line is unchanged. It is resolved once per request, inside the deferred access log, so it costs nothing on skipped (streaming) paths.
func WithClientIPFunc ¶ added in v1.2.0
WithClientIPFunc is like WithClientIP but resolves the "client_ip" attribute with a caller-supplied function instead of a fixed trusted-proxy set. Use it when the trusted set is not known at construction — e.g. it is reloaded from config at runtime behind a hot-reloadable resolver — or when client-IP resolution is otherwise app-specific: fn is called once per logged request (never on a skipped path), and its result is logged verbatim as "client_ip". It composes with WithRecordMetric. WithClientIP and WithClientIPFunc both enable the attribute and are mutually exclusive; whichever is applied last wins. A nil fn is ignored (matching the package's skip-nil option convention), so a trailing WithClientIPFunc(nil) neither enables the attribute nor clears a prior WithClientIP.
func WithLogLevel ¶ added in v1.13.0
WithLogLevel sets the LEVEL POLICY for the access-log line: fn is called once per logged request with the request and the final status, and the line is emitted at the returned level. The default without this option is slog.LevelInfo, unchanged. The hook chooses the level only — the line's attributes and emission rules (deferred emit, skip paths) are the logger's fixed mechanism. ProbeLogLevel is the named preset over this hook for the routine-machine-probe case; reach for the raw hook only when a custom policy is genuinely needed.
The canonical use is scrape-noise control on a polled service: map 2xx/3xx to slog.LevelDebug so a 15-second Prometheus scrape stays out of the log stream at the default level while staying visible under LOG_LEVEL=debug, and raise 4xx to Warn / 5xx to Error so failures surface. Because fn also receives the request, a policy can key on the path instead (quiet only the scrape route, keep everything else at Info).
A request suppressed by WithSkipPaths or WithSkipFunc emits no line at all, so fn is never called for it. A panicking fn is contained: the failure is logged and the line falls back to Info, mirroring the package's other callback guards. A nil fn is ignored (the skip-nil option convention).
func WithLogger ¶
WithLogger sets the slog.Logger used for access-log lines. Defaults to slog.Default().
func WithMaxLoggedPath ¶ added in v1.19.0
WithMaxLoggedPath sets the byte cap the access line applies to the recorded path, replacing the 512-byte default. Reach for it when the app's whole route table is short and a tighter log-line budget is worth more than the tail of a long path: knell serves /healthz, /metrics, and /beat/{id} with an id its config caps at 64 characters, so 128 covers every path it can legitimately answer.
n counts PATH bytes. An over-cap value keeps at most n of them, cut on a UTF-8 rune boundary, followed by the 14-byte truncation marker, so the attribute is at most n+14 bytes; a value within the cap is recorded byte-identical. The cap applies to whatever the path policy produced, including this package's own fail-closed placeholders, so the guarantee has no branch-shaped hole.
A non-positive n is ignored and the default stands (the skip-nil option convention). There is deliberately no way to switch the bound OFF: it exists because eight of the nine access-log consumers had no path bound of their own, so an option that could zero it would reopen precisely the hole it closes — and a config-driven 0 would do it silently.
func WithPathFunc ¶ added in v1.14.0
WithPathFunc sets the PATH POLICY for the access-log line: fn is called once per logged request, at emit time, and its return value replaces r.URL.Path as the recorded path. Use it when a route embeds a credential or other sensitive segment (e.g. "/api/sessions/{token}") that should be logged as a token-free template or truncated form — the middle ground between logging the raw path and losing the whole access record to WithSkipPaths or WithSkipFunc.
The returned value is "the path as recorded": it feeds the access line's "path" attribute, the legacy WithRecordMetric hook's path argument, and the "path" attribute of the package's hook-failure diagnostics. It feeds NEITHER request-derived metric hook: WithRecordMetricRequest receives the request itself and owns its own representation, and WithRecordRouteMetric's path label is the matched route, which is a bound of its own and not a redaction policy. fn's return is still length-bounded before it is recorded (512 bytes by default, WithMaxLoggedPath to change it): a transform is a REDACTION policy, and nothing about redacting a token also bounds the path it was in.
fn runs inside the deferred emit, after routing, so http.ServeMux has already populated r.Pattern (empty when nothing matched) and a transform may return the matched template with its own fail-closed fallback for unmatched requests. The WithRecordMetricRequest caveat applies equally: middleware between RequestLogger and the mux that replaces the request (r.WithContext and friends return a clone) hides the populated fields.
Skip predicates (WithSkipPaths, WithSkipFunc) always test the raw r.URL.Path, and a skipped request never calls fn.
Fail-closed: if fn panics or returns the empty string, the line records the "(path-redaction-failed)" placeholder — never the raw path — and a panic is additionally logged through the package's hook-isolation guard, whose own diagnostic also omits the raw path. A nil fn is ignored (the skip-nil option convention).
func WithRecordMetric ¶
WithRecordMetric registers a hook invoked once per logged request with the final method, path, status, and latency. Both text arguments are the values the access line RECORDED, so they carry the same bounds: the path is the path policy's output capped by WithMaxLoggedPath, and the method is capped at 24 bytes, which keeps an over-long token out of a metric label as well as out of the log. It fires from a deferred call, so a panicking handler is still recorded. Requests skipped via WithSkipPaths or WithSkipFunc are excluded from the hook as well as from access logging: a stream's open-to-close duration paired with a synthetic status is misleading, which is the whole reason the path is skipped.
Its bounds are LENGTH bounds, not cardinality bounds: a raw r.URL.Path under the cap is still one label value per URL a scanner invents, and a method under 24 bytes is still one per token. For a metric this hook is the wrong shape unless a path policy already collapses the path onto route templates — reach for WithRecordRouteMetric, whose labels are bounded by construction. The three metric options are mutually exclusive; whichever is applied last wins.
func WithRecordMetricRequest ¶ added in v1.13.0
WithRecordMetricRequest is the request-aware variant of WithRecordMetric: fn is invoked once per logged request with the *http.Request itself, the final status, and the latency. Because http.ServeMux assigns the matched pattern to the request in place, fn observes a populated r.Pattern after routing (empty when nothing matched, e.g. a 404), so a caller can key a metric on the route TEMPLATE rather than on the raw URL path. Caveat: middleware between RequestLogger and the mux that replaces the request (r.WithContext and friends return a clone) hides those fields — the mux populates the clone, not the request this hook received.
Prefer WithRecordRouteMetric. This hook hands fn the raw request, which makes the APP responsible for a bound it can get wrong, and one consumer did: it derived its labels from r.Method, wrote a comment asserting they were bounded, and shipped an attacker-controllable method label anyway, because it also registers a "/" catch-all — so its r.Pattern is never empty, the unmatched collapse it relied on can never fire, and every SPA fallthrough minted a series named by the caller. Nothing in the hook's signature could have caught that. WithRecordRouteMetric computes the bounded pair here and passes it in, so there is no derivation left for a consumer to get wrong; reach for this hook only when the metric genuinely needs something else from the request (say a per-tenant series keyed on an id the app validated itself), and use RouteMetricLabels when what it needs is the standard pair.
Like WithRecordMetric it fires from a deferred call (a panicking handler is still recorded) and is excluded on paths skipped via WithSkipPaths or WithSkipFunc. The three metric options are mutually exclusive; whichever is applied last wins. A nil fn is ignored (the package's skip-nil option convention), so a trailing WithRecordMetricRequest(nil) neither enables the hook nor clears a prior hook.
func WithRecordRouteMetric ¶ added in v1.19.0
WithRecordRouteMetric registers the metric hook whose labels THIS PACKAGE derives, and is the recommended one: fn is invoked once per logged request with the bounded (method, path) label pair RouteMetricLabels computes for the request, plus the final status and the latency. The app never receives the raw request through this option, so it has no derivation to get wrong — that is the whole reason the option exists rather than only the function. Safety here is a property of the wiring, not of every consumer remembering to reach for the right helper: WithRecordMetricRequest's godoc records the consumer that reached for the wrong one, believed otherwise, and shipped an attacker-controllable method label.
The labels are bounded BY CONSTRUCTION: the method is one of ten fixed values and the path is either a pattern the SERVER registered or the fixed "unmatched" marker. Cardinality is therefore a function of the route table, not of the traffic, which matters because the hook fires from the access-log defer — outside every app auth gate — and a series once minted is permanent for the process lifetime here AND in every observer scraping it. See RouteMetricLabels for each label's derivation and for the one deliberate divergence from the access line.
fn takes the same (method, path, status, duration) arguments as WithRecordMetric rather than a struct, so that switching a consumer from the unbounded hook to this one changes the option name and nothing else, and one recording function can be passed to either.
Like the other metric hooks it fires from a deferred call (a panicking handler is still recorded), is excluded on paths skipped via WithSkipPaths or WithSkipFunc, and is isolated by a recover guard (a panicking hook skips this request's metric rather than killing the connection). The three metric options are mutually exclusive; whichever is applied last wins. A nil fn is ignored (the package's skip-nil option convention), so a trailing WithRecordRouteMetric(nil) neither enables the hook nor clears a prior hook.
The path policy options (WithPathFunc, WithTemplatePathsUnder, WithMaxLoggedPath) do NOT reach these labels: they govern the recorded path of the LOG LINE, while this hook's path is the matched route. A path policy and this hook are complementary, not alternatives.
func WithSkipFunc ¶
WithSkipFunc registers a predicate; when it returns true for a request, that request is passed through WITHOUT an access-log line or metric (like a WithSkipPaths match), while the request id is still minted, echoed, and threaded. Use it for streaming routes with path parameters (e.g. "/ws/{id}") that an exact WithSkipPaths match cannot cover.
func WithSkipPaths ¶
WithSkipPaths marks exact request paths (compared against r.URL.Path) that should pass through WITHOUT an access-log line AND without a metric hook. Use it for long-lived streams (SSE, WebSocket) whose single open-forever request would otherwise emit one misleading high-latency line and a synthetic status at close. The request id is still minted, echoed, and threaded into the context for skipped paths. Because the match is exact, streaming routes with path parameters (e.g. "/ws/{id}") need WithSkipFunc instead.
func WithSkipUpgrades ¶ added in v1.20.0
func WithSkipUpgrades() LogOption
WithSkipUpgrades suppresses the access record for a request whose response SWITCHED PROTOCOLS — and for nothing else. It is what a WebSocket route should use instead of a WithSkipFunc predicate that PREDICTS which requests will upgrade.
The decision comes from the response, not from the request. The record is suppressed when the recorded status is 101 (the handshake went through the ResponseWriter, as coder/websocket's WriteHeader(101)-then-hijack does) or when the handler hijacked the connection before recording anything at all (the handler wrote the handshake onto the connection itself, as gorilla/websocket does). Those are the two shapes a completed upgrade takes, and in both the exchange has ENDED rather than completed, so the one line that would be emitted when the socket finally closes — hours later, carrying a session-length duration and a status net/http never sent — describes something that never happened. Every OTHER outcome on the same route keeps its record with its real status, duration, request id, and client ip: the 400 for a malformed Sec-WebSocket-Key, the 403 for a disallowed origin, the 405 for a non-GET, the 426 for missing upgrade headers. Those refusals are what an operator greps for when a browser cannot attach or a reverse proxy mangles a handshake.
Why the decision belongs here: a skip predicate has to answer before the handler runs, so it must model the handshake policy of whichever library will answer the request, and it drifts when that library changes. A consumer's predicate checked that Sec-WebSocket-Key was present exactly once; coder/websocket base64-decodes that header and requires exactly 16 bytes, answering 400 otherwise, so every malformed-key 400 was suppressed as if it had upgraded — as was the cross-origin 403 the same predicate could not model — losing status, duration, request id, and client ip for exactly the refused requests worth seeing. The fact the predicate was guessing at is known HERE, once the response exists.
The interaction with the skip options is one-way. WithSkipPaths and WithSkipFunc are evaluated before the handler and bypass the recorder entirely, so a request either of them matches is skipped whatever status it ends up with, and this option cannot bring its record back. This option only ever REMOVES a record that would otherwise have been emitted.
Suppression removes the whole record, the same pairing WithSkipPaths has and for the same reason: no access line and no metric hook, and neither the WithLogLevel policy nor a WithPathFunc / WithTemplatePathsUnder transform is consulted. The request id is still minted, echoed, and threaded, as on every path through this middleware. A handler that PANICS after the switch is not hidden by that: Recoverer logs the panic and its stack from its own line, which is where a crash mid-session belongs — the access line it would otherwise pair with says 101, not 500, because the status was decided at the handshake.
Two boundaries, because both KEEP their record:
- A handler that never calls WriteHeader records the implicit 200 net/http sends. That is not 101, so an ordinary request that writes only a body is logged exactly as it is without this option.
- A handler that writes an explicit status and THEN hijacks (an HTTP CONNECT tunnel answering 200) keeps its record with that status: it told us what it answered, and only 101 says "this is no longer an HTTP exchange". A consumer that wants such a tunnel silent still has WithSkipFunc, whose prediction problem does not apply to a test on the method.
It takes no status argument on purpose. 101 is the one status that means the exchange ended rather than completed, so a general skip-these-statuses option would add exactly one capability: silencing refusals. A 404 flood is a broken route and a 401 flood is an attack, and deleting their records is CWE-778 rather than noise control — which WithLogLevel (or the ProbeLogLevel preset) already does properly, by lowering a line's level instead of removing it.
HTTP/2's extended-CONNECT upgrade (RFC 8441) answers 2xx rather than 101 and is deliberately out of scope; it cannot arise for this option's audience anyway, since a server that hijacks to speak WebSocket needs HTTP/1.1.
func WithTemplatePathsUnder ¶ added in v1.17.0
WithTemplatePathsUnder declares URL prefixes whose concrete paths carry a CREDENTIAL, so the access log records the matched ROUTE TEMPLATE for them instead of the path itself: "/api/sessions/{id}" rather than "/api/sessions/6f3a…". Prefer it over WithPathFunc for this job — it is the same protection expressed as data, and the reason that matters is below.
The template comes from r.Pattern, which http.ServeMux populates during routing, so the router is the source of truth for what matched. The method prefix ServeMux includes ("DELETE /api/sessions/{id}") is stripped, since the method is already its own attribute on the line.
Three cases, and the middle one is the whole point:
- a path under a declared prefix that MATCHED a route: the template.
- a path under a declared prefix that matched NOTHING (a 404 on "/api/sessions/6f3a…/nope"): the prefix plus an "(unmatched)" marker. Never the raw path — an unrouted request under a credential-bearing prefix still has the credential in it, and this is exactly the leak a path policy exists to close. It is also visible as unmatched rather than mislabelled onto a route it is not, so a NEW upstream subroute shows up in the log as something to wire rather than disappearing.
- a path outside every declared prefix: recorded unchanged. Deliberately not the template, because a static mount's pattern is "/" and logging that would collapse every asset onto one line, losing which asset 404'd. Credential-bearing routes are a per-route-family fact, not a per-app one, which is why this option takes prefixes rather than being a global switch.
Why this exists as a declarative option rather than leaving callers to write their own WithPathFunc: two apps in this fleet hand-rolled the identical transform over the same upstream route table and DIVERGED on the unmatched case — one returned "" (indistinguishable from a broken transform) and one returned an "(unmapped)" marker. A free-form hook makes that outcome the default. Expressed as data, the policy has one implementation, and the unmatched-route decision is made once here instead of once per consumer.
Pair it with the prefix the route-owning package exports (e.g. the terminal engine's SessionsSubtreePath) rather than a local string literal, so the set of credential-bearing routes stays owned by whoever declares those routes.
A prefix that is empty is ignored. Applying this option replaces any previously-set path policy (including WithPathFunc), and vice versa: there is one recorded path, so the last policy applied wins.
type Middleware ¶
Middleware is the standard net/http decorator shape: it wraps an http.Handler and returns a new one. It is a type alias, so any plain func(http.Handler) http.Handler value is a Middleware without conversion.
func Logging ¶
func Logging(opts ...LogOption) Middleware
Logging returns a Chain-composable Middleware wrapping RequestLogger with the given options. It is exactly RequestLogger in middleware form; use RequestLogger directly when you are not composing with Chain. See RequestLogger for the request-id and access-log behavior and the available LogOption values.
func NoStore ¶ added in v1.20.0
func NoStore() Middleware
NoStore returns middleware that sets Cache-Control: no-store on every response passing through it, before the next handler runs.
The value is FIXED. This is deliberately not a cache-policy API: the one header every dynamic surface needs is worth a name, and anything richer is per-asset POLICY that already has a home in WithStaticCacheControl. Three consumers had hand-written this exact three-line wrapper — for a state-reporting API, for an SPA's HTML and API responses, and for an /api/ subtree whose handlers set no cache directive at all — and the library already set the same value internally at ReadinessHandler without exporting a composable form.
PLACEMENT AND OVERRIDE ORDERING STAY APP-OWNED, exactly like WithStaticCacheControl's policy hook. The header is set before next runs and is not locked: a handler or an inner middleware that needs a different value for its own responses (a long-lived immutable asset, a per-asset cache policy, a cacheable preview image) simply Sets Cache-Control itself and wins, which is why the usual placement is innermost in the Chain — early enough to land before any handler writes a status, late enough that the asset paths that need to override it still can. Scope it by mounting it on the subtree that needs it rather than by configuring it.
It is NOT the middleware for a conditional no-store: a response that must only be uncacheable when it carries a Set-Cookie (the OWASP session- management rule) has a different trigger and a different concern, and belongs with the code that sets the cookie.
func RateLimiter ¶ added in v1.3.0
func RateLimiter(burst int, interval time.Duration, opts ...RateLimitOption) Middleware
RateLimiter returns middleware that throttles requests through a single shared token bucket (standard library only, no external dependency). burst is the maximum number of tokens and the initial fill; interval is the time to accrue one token (the refill cadence). Each admitted request consumes one token; a request that arrives with the bucket empty is answered with a 429 via WriteError(w, r, http.StatusTooManyRequests, "rate_limited", "rate limit exceeded") (code and message overridable with WithRateLimitError) and does not reach the next handler. The 429 carries a conservative Retry-After hint: the whole seconds for the CURRENT token deficit to refill at the configured rate — at most ceil(interval) when the bucket was just emptied, less when it is already partially refilled — clamped to at least 1s.
The bucket is process-wide for the middleware instance — shared across all requests and all clients — so it bounds the AGGREGATE rate of the wrapped route. That is the right tool for capping an expensive shared resource (for example, spawning a heavy child process per request), not for per-client fairness. Per-client limiting is intentionally out of scope; a caller behind a trusted proxy that needs it can key its own buckets on ClientIP.
A non-positive burst or a non-positive interval disables limiting: the middleware returns the next handler unwrapped (mirroring RouteTimeout's non-positive "off" contract), so a config-driven zero cleanly means "no limit".
Apply it to the specific handler you want to throttle, and pair it with WithRateLimitWhen to gate only the expensive method+path when the handler serves several:
sessions := webhttp.RateLimiter(6, time.Second,
webhttp.WithRateLimitWhen(func(r *http.Request) bool {
return r.Method == http.MethodPost
}),
)(sessionsHandler)
func Recoverer ¶
func Recoverer(opts ...RecoverOption) Middleware
Recoverer returns middleware that recovers a panic from a downstream handler, logs it at Error with the stack and the request id (via RequestIDFromContext), fires any WithPanicHook callback, and writes a 500 error via the configured ErrorResponder - WriteError by default, i.e. the JSON envelope {"error":"internal server error","code":"internal_error"}. Override the responder with WithRecoverResponder to render the 500 on another content type. Without it, a handler panic unwinds to net/http, which closes the connection abruptly with no response body.
The http.ErrAbortHandler sentinel is deliberately NOT recovered: per the net/http contract it is re-panicked so the server aborts the response the way the handler intended (it is not logged and fires no hook).
Placement relative to Logging matters. Put Recoverer INSIDE RequestLogger, i.e. Logging outermost, as in Chain(mux, Logging(), Recoverer()): the recovered request then records a 500 before RequestLogger's deferred access line runs, so the request logs as 500. If Recoverer sits OUTSIDE the logger, RequestLogger's deferred line runs during the panic unwind and records the StatusRecorder's default 200, a misleading access line even though the client still receives the 500.
The 500 is best-effort and never double-writes: if the handler already committed the response (wrote headers or body) before panicking, the status is on the wire and cannot be changed, so Recoverer skips the body entirely (it still logs the panic and fires the hook) rather than corrupting the partial response or mislabeling its status under an outer Logging. To detect commitment it observes the response through a StatusRecorder, reusing an existing one (such as RequestLogger's) when present.
func SecurityHeaders ¶
func SecurityHeaders(opts ...SecurityOption) Middleware
SecurityHeaders returns middleware that sets a baseline of response security headers before calling the next handler. Set by default are X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin. The X-Frame-Options and Referrer-Policy defaults are configurable (override with a value, or omit with an empty string); nosniff is set by default with no option to change it here. Content-Security-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, and Strict-Transport-Security are off unless their options are supplied.
All of these are set BEFORE next runs, so none is immutable: the middleware establishes the baseline but does not lock it. A handler that needs a different value for a specific response can still override (or delete) any of them, nosniff included, on the response header.
func SessionCreateRateLimit ¶ added in v1.8.0
func SessionCreateRateLimit(path string) Middleware
SessionCreateRateLimit returns middleware gating POST requests to path (an exact match, e.g. "/api/sessions") behind the standard session-create token bucket: burst 6, one token accrued per second, and a 429 envelope of code "rate_limited" / message "session creation rate exceeded". Non-POST methods and other paths pass through without consuming a token, so a handler that multiplexes list (GET) and close (DELETE) on the same path stays unthrottled.
This is a preset over RateLimiter for the create-a-heavy-child endpoint shape, where each admitted request forks an expensive process (a PTY, an agent subprocess) and what needs bounding is aggregate create churn. The tuning lives here once; an app needing different numbers or a different predicate composes RateLimiter directly.
type RateLimitOption ¶ added in v1.3.0
type RateLimitOption func(*rateLimitConfig)
RateLimitOption configures RateLimiter.
func WithRateLimitError ¶ added in v1.3.0
func WithRateLimitError(code, msg string) RateLimitOption
WithRateLimitError sets the error code and message written in the 429 JSON envelope (via WriteError). Defaults to "rate_limited" / "rate limit exceeded".
func WithRateLimitWhen ¶ added in v1.3.0
func WithRateLimitWhen(pred func(*http.Request) bool) RateLimitOption
WithRateLimitWhen restricts limiting to requests for which pred returns true; every other request passes through without consuming a token. Use it to gate a single method+path on a handler that multiplexes several — for example, throttle only POST and leave GET/DELETE on the same path unthrottled. A nil predicate is ignored, so the default (limit every request) stands.
type ReadinessChecker ¶
type ReadinessChecker interface {
// Ready reports whether the service should receive traffic right now.
Ready() bool
}
ReadinessChecker is the readiness view ReadinessHandler needs. *Ready satisfies it; a service with a composite readiness model can supply its own implementation.
type Ready ¶
type Ready struct {
// contains filtered or unexported fields
}
Ready is a concurrency-safe readiness flag. Its zero value reports not ready, so a service starts unready until it calls Set(true) once initialization completes, and can flip back to unready during shutdown.
type RecoverOption ¶
type RecoverOption func(*recoverConfig)
RecoverOption configures Recoverer.
func WithPanicHook ¶
func WithPanicHook(fn func(v any, stack []byte)) RecoverOption
WithPanicHook registers a callback invoked with the recovered value and the captured stack when a panic is caught. Use it to increment a metric or notify an error tracker. It runs after the panic is logged and before the 500 response is written; a nil hook is ignored.
func WithRecoverLogger ¶
func WithRecoverLogger(l *slog.Logger) RecoverOption
WithRecoverLogger sets the slog.Logger used to report a recovered panic. Defaults to slog.Default().
func WithRecoverResponder ¶ added in v1.4.0
func WithRecoverResponder(fn ErrorResponder) RecoverOption
WithRecoverResponder sets the ErrorResponder that writes the 500 body after a recovered panic (only when the response is not already committed). It defaults to WriteError - the JSON envelope; supply one to render the 500 on a different content type, for example an XML endpoint returning its own error document. The responder owns writing the status and headers. A nil responder is ignored, keeping the default.
type RunOption ¶
type RunOption func(*runConfig)
RunOption configures Run.
func WithPreDrain ¶ added in v1.9.0
WithPreDrain registers a hook Run invokes after ctx is cancelled and strictly before srv.Shutdown starts draining in-flight requests. It is the place for the release-the-streams phase a graceful stop needs ahead of the drain: flip a readiness gate so a load balancer stops routing here, cancel the server's BaseContext or shut down an SSE hub so long-lived connections end (otherwise Shutdown waits its full grace period for them). fn receives a context bounded by the SAME shutdown deadline that Shutdown and onShutdown share; whatever budget it spends is no longer available to them. A nil fn is ignored.
func WithServeExit ¶ added in v1.18.0
WithServeExit registers an opt-in teardown hook Run invokes when srv.Serve returns on its own — before ctx is ever cancelled — in place of the graceful sequence. That covers a fatal serve error (the listener or the accept loop is gone) and a Shutdown or Close the caller drove itself outside Run. Exactly one of the two paths runs per Run call: with the hook registered, either pre-drain -> Shutdown -> onShutdown ran, or fn did. Without it, Run keeps today's behavior of returning the serve error with NEITHER hook invoked, so an existing caller sees no change. A nil fn is ignored.
fn receives a context bounded by the shutdown grace period, and gets the whole budget: there was no drain to share it with. Run does NOT call srv.Shutdown on this path — Serve has already returned and closed the listener, so a drain would only spend fn's budget waiting on connections whose accept loop is already dead.
WithPreDrain and onShutdown are NOT reused here, because both are documented against a graceful stop that this path is not: ctx is still LIVE (nothing cancelled it), and there is no drain for a pre-drain phase to precede. That matters for what fn must do — a teardown that waits on a goroutine keyed to ctx (a background loop stopped by the same signal context) has to cancel it inside fn, by calling the signal context's stop function, or it waits out the whole grace for a goroutine nothing has asked to stop. A caller whose teardown is cancellation-independent can pass the same function to both slots: Run(ctx, srv, ln, teardown, WithServeExit(teardown)).
func WithShutdownGrace ¶
WithShutdownGrace sets how long Run allows for graceful shutdown: the window for the pre-drain hook to run, for in-flight requests to finish, and for the onShutdown teardown to run. Defaults to 5s.
type SecurityOption ¶
type SecurityOption func(*securityConfig)
SecurityOption configures SecurityHeaders.
func WithCOOP ¶
func WithCOOP(v string) SecurityOption
WithCOOP sets the Cross-Origin-Opener-Policy header (e.g. "same-origin"). Unset by default.
func WithCSP ¶
func WithCSP(policy string) SecurityOption
WithCSP sets the Content-Security-Policy header. The library never builds a policy for you: a CSP is application-specific (it must match the app's script and style sources, nonces, or hashes), so pass the exact policy string the app needs. Unset by default.
func WithFrameOptions ¶
func WithFrameOptions(v string) SecurityOption
WithFrameOptions overrides the X-Frame-Options header (default "DENY"). Pass an empty string to omit the header, for example when a Content-Security-Policy frame-ancestors directive supersedes it.
func WithHSTS ¶
func WithHSTS(maxAge time.Duration, includeSubdomains, preload bool) SecurityOption
WithHSTS enables the Strict-Transport-Security header with the given max-age and the includeSubDomains and preload directives. HSTS is OFF by default: enable it only for a service reached exclusively over HTTPS, because a browser that sees the header will refuse plain-HTTP and untrusted-certificate connections to the host for the whole max-age window. A negative max-age is clamped to zero (which instructs browsers to forget the policy).
func WithPermissionsPolicy ¶
func WithPermissionsPolicy(v string) SecurityOption
WithPermissionsPolicy sets the Permissions-Policy header (browser feature gating, e.g. "geolocation=(), camera=()"). Unset by default.
func WithReferrerPolicy ¶
func WithReferrerPolicy(v string) SecurityOption
WithReferrerPolicy overrides the Referrer-Policy header (default "strict-origin-when-cross-origin"). Pass an empty string to omit it.
type ServerOption ¶
ServerOption configures the *http.Server built by NewServer.
func WithErrorLog ¶
func WithErrorLog(l *log.Logger) ServerOption
WithErrorLog sets http.Server.ErrorLog so connection-level errors go to the caller's logger instead of the standard logger. It is the override for a custom *log.Logger; for the ordinary case of routing those lines into slog at a chosen level, use WithSlogErrorLog.
func WithIdleTimeout ¶
func WithIdleTimeout(d time.Duration) ServerOption
WithIdleTimeout sets http.Server.IdleTimeout, the keep-alive idle deadline.
func WithMaxHeaderBytes ¶
func WithMaxHeaderBytes(n int) ServerOption
WithMaxHeaderBytes sets http.Server.MaxHeaderBytes.
func WithReadHeaderTimeout ¶
func WithReadHeaderTimeout(d time.Duration) ServerOption
WithReadHeaderTimeout sets http.Server.ReadHeaderTimeout, the slowloris guard bounding how long a client may take to send request headers.
func WithReadTimeout ¶
func WithReadTimeout(d time.Duration) ServerOption
WithReadTimeout sets http.Server.ReadTimeout, the deadline for reading the entire request. Leave it unset for streaming request bodies.
func WithSlogErrorLog ¶ added in v1.20.0
func WithSlogErrorLog(level slog.Level) ServerOption
WithSlogErrorLog sets http.Server.ErrorLog to a bridge that forwards net/http's own connection-level lines into slog at level, so they arrive as level-carrying records in an otherwise structured stream instead of as unstructured, level-less standard-logger output that no level-based log rule can match. The lines it covers are net/http's, above all "http: Accept error: ...; retrying" — the trace of an exhausted fd budget, which is a whole-service outage no request-scoped log will report.
The LEVEL is the caller's policy and deliberately has no default here, because consumers legitimately disagree: an accept failure is fatal to a service whose only job is to answer probes (Error) and a degradation to one that will retry (Warn). This option only gives that choice a named home instead of three hand-written copies of the same slog.NewLogLogger recipe.
It resolves slog.Default() when the option is APPLIED (inside NewServer), so install the process logger before building the server; a later slog.SetDefault does not retroactively re-target an already-built http.Server. WithErrorLog remains the escape hatch for any other *log.Logger, and the default (net/http's standard logger) is unchanged when neither option is passed.
func WithWriteTimeout ¶
func WithWriteTimeout(d time.Duration) ServerOption
WithWriteTimeout sets http.Server.WriteTimeout, the deadline for writing the entire response. It is unset by default: streaming apps (SSE, WebSocket, long responses) MUST omit it, because it would cut off an in-progress stream.
type StaticOption ¶ added in v1.8.0
type StaticOption func(*staticConfig)
StaticOption configures StaticHandler.
func WithStaticCacheControl ¶ added in v1.8.0
func WithStaticCacheControl(fn func(assetPath string) string) StaticOption
WithStaticCacheControl sets the per-asset Cache-Control policy. fn receives the normalized asset path — no leading slash, and "index.html" for the root ("/") request — and returns the Cache-Control value for that asset; an empty return omits the header. The default policy returns "no-cache" for every asset: revalidate on every load (the content-hash ETag makes that a cheap 304), the right choice when asset paths are stable rather than content-addressed. Supply a policy to differ per path, for example immutable large fonts alongside no-cache app code. A nil fn is ignored, keeping the default.
The policy is MECHANISM-free: it chooses header values only. Whether an asset is ETagged, gzipped, or revalidated is the handler's fixed behavior.
type StaticTokenVerifier ¶ added in v1.10.0
type StaticTokenVerifier struct {
// contains filtered or unexported fields
}
StaticTokenVerifier verifies presented credentials against the single operator-configured secret guarding an endpoint. It is the constant-time verification primitive for static machine credentials — an API key, a bearer token, a basic-auth user or password — where exactly one expected value comes from configuration. It verifies one shared secret, not user identities: per-user credential stores, password hashing, and session management belong to the auth library (github.com/cplieger/auth), not here.
Construct it once with NewStaticTokenVerifier and reuse it for every request. The configured secret is hashed with SHA-256 at construction; Verify hashes only the presented value and compares the two fixed-length digests with subtle.ConstantTimeCompare. Comparing 32-byte digests rather than the raw strings removes ConstantTimeCompare's unequal-length short-circuit (CWE-208), and pre-hashing the configured secret means no per-call timing varies with the secret's length or content — hashing the presented value per call reveals only what the caller already knows: its own input.
The zero value fails CLOSED: Verify returns false for every presented value until the verifier is constructed from a non-empty secret.
func NewStaticTokenVerifier ¶ added in v1.10.0
func NewStaticTokenVerifier(configured string) StaticTokenVerifier
NewStaticTokenVerifier builds a verifier for the configured secret.
An empty configured secret yields the zero verifier, which fails CLOSED: Verify returns false for every presented value, including the empty string. Without that guard the digest comparison would fail OPEN — sha256("") equals sha256(""), so an unset secret would grant access to any client that simply presents no credential. Treat an empty configured value as "auth not configured", never as "no credential required": a caller that wants an open endpoint should skip the gate explicitly rather than rely on an empty secret.
Example ¶
package main
import (
"fmt"
"github.com/cplieger/webhttp"
)
func main() {
v := webhttp.NewStaticTokenVerifier("s3cr3t")
fmt.Println(v.Verify("s3cr3t"))
fmt.Println(v.Verify("wrong"))
// An unset secret fails closed: even an empty presented value is rejected.
unset := webhttp.NewStaticTokenVerifier("")
fmt.Println(unset.Verify(""))
}
Output: true false false
func (StaticTokenVerifier) Verify ¶ added in v1.10.0
func (v StaticTokenVerifier) Verify(presented string) bool
Verify reports whether presented matches the configured secret. It is safe for concurrent use.
type StatusRecorder ¶
type StatusRecorder struct {
http.ResponseWriter
// contains filtered or unexported fields
}
StatusRecorder wraps an http.ResponseWriter to capture the response status code. Middleware that needs to observe the final status (access logging, metrics) wraps the writer in a StatusRecorder.
It stays transparent to streaming in two complementary ways. The Unwrap method lets http.NewResponseController walk to the underlying writer, so ResponseController-based callers reach its Flusher, Hijacker, and deadline setters. It also implements http.Flusher, http.Hijacker, and io.ReaderFrom directly, so a handler or library that type-asserts those interfaces on the writer (as gorilla/websocket does with w.(http.Hijacker)) still works, and io.Copy/http.ServeContent keep the zero-copy sendfile fast path. Each passthrough returns the underlying writer's own result, so, for example, Hijack reports http.ErrNotSupported on an HTTP/2 stream.
func NewStatusRecorder ¶
func NewStatusRecorder(w http.ResponseWriter) *StatusRecorder
NewStatusRecorder wraps w. The recorded status defaults to http.StatusOK (200) and stays there until WriteHeader or the first Write records a status.
func (*StatusRecorder) Flush ¶
func (s *StatusRecorder) Flush()
Flush forwards to the underlying writer via http.ResponseController (which walks the Unwrap chain), so a handler that type-asserts http.Flusher directly still streams through the recorder. A no-op if the underlying writer cannot flush.
func (*StatusRecorder) Hijack ¶
func (s *StatusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
Hijack forwards to the underlying writer via http.ResponseController for libraries that hijack the connection through a direct http.Hijacker assertion. Returns an error (e.g. http.ErrNotSupported) when the underlying writer, such as an HTTP/2 stream, cannot be hijacked.
func (*StatusRecorder) ReadFrom ¶
func (s *StatusRecorder) ReadFrom(src io.Reader) (int64, error)
ReadFrom preserves the zero-copy (sendfile) fast path for io.Copy and http.ServeContent when the underlying writer implements io.ReaderFrom. Writing the body implies a 200 status when WriteHeader was not called.
func (*StatusRecorder) Status ¶
func (s *StatusRecorder) Status() int
Status returns the recorded status code, or http.StatusOK if none was set.
func (*StatusRecorder) Unwrap ¶
func (s *StatusRecorder) Unwrap() http.ResponseWriter
Unwrap returns the wrapped http.ResponseWriter. http.NewResponseController walks Unwrap to reach a Flusher, Hijacker, or other optional interface implemented by the underlying writer, so streaming still works through the recorder.
func (*StatusRecorder) Write ¶
func (s *StatusRecorder) Write(b []byte) (int, error)
Write records an implicit 200 status on the first write when WriteHeader was not called (mirroring net/http), then forwards to the wrapped writer.
func (*StatusRecorder) WriteHeader ¶
func (s *StatusRecorder) WriteHeader(code int)
WriteHeader records the first explicit status code and forwards every call to the wrapped writer. Only the first code is recorded, matching net/http's first-code-wins semantics; a later call still forwards (so net/http logs its standard "superfluous WriteHeader" warning) but does not change Status.
func (*StatusRecorder) Wrote ¶
func (s *StatusRecorder) Wrote() bool
Wrote reports whether the response has been committed, i.e. WriteHeader, the first Write or ReadFrom, or a successful Flush or Hijack has run. Middleware such as Recoverer consults it to avoid writing a second status and body onto a response a handler has already started, which would corrupt the body and mislabel the status.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package sse provides a broadcast hub for Server-Sent Events: fan-out to connected clients, a replay ring with monotonic event IDs so a reconnecting client resumes via the standard Last-Event-ID header, proxy-defensive response headers, keepalive comments, an optional concurrent-client cap, per-subscriber topic filtering, and a shutdown drain gate.
|
Package sse provides a broadcast hub for Server-Sent Events: fan-out to connected clients, a replay ring with monotonic event IDs so a reconnecting client resumes via the standard Last-Event-ID header, proxy-defensive response headers, keepalive comments, an optional concurrent-client cap, per-subscriber topic filtering, and a shutdown drain gate. |