check / check (push) Waiting to run
Recoverer comes first, so it wrote the 500 only after the panic had passed the request log and the metrics, which both recorded 200. Write500OnPanic, placed after the request log and after the metrics, writes the 500 as the panic passes and panics again, so Sentry and Recoverer still get it. Recoverer now writes its 500 only when no status was written, so the server logs no superfluous WriteHeader call. The test also requires Recoverer's log line for the handler's panic, so it fails if Write500OnPanic stops passing the panic on. Model: opus-5-5
431 lines
12 KiB
Go
431 lines
12 KiB
Go
// Package middleware provides HTTP middleware for logging,
|
|
// CORS, and other cross-cutting concerns.
|
|
package middleware
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/netwatch/internal/config"
|
|
"sneak.berlin/go/netwatch/internal/globals"
|
|
"sneak.berlin/go/netwatch/internal/logger"
|
|
|
|
basicauth "github.com/99designs/basicauth-go"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/go-chi/httprate"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
|
"github.com/slok/go-http-metrics/middleware/std"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
const corsMaxAgeSec = 300
|
|
|
|
// jsonErrorBody is the body written for errors raised inside
|
|
// middleware, matching the {"status":"error"} shape the handlers
|
|
// return so clients see one error contract across the API.
|
|
const (
|
|
jsonContentType = "application/json; charset=utf-8"
|
|
jsonErrorBody = "{\"status\":\"error\"}\n"
|
|
)
|
|
|
|
// Security header values. The backend is a JSON API with no
|
|
// HTML surface, so the CSP forbids every resource type and
|
|
// framing outright.
|
|
const (
|
|
hstsValue = "max-age=31536000; includeSubDomains"
|
|
cspValue = "default-src 'none'; frame-ancestors 'none'"
|
|
permissionsPolicyValue = "camera=(), microphone=(), geolocation=()"
|
|
)
|
|
|
|
// Params defines the dependencies for Middleware.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Config *config.Config
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Middleware holds shared state for middleware factories.
|
|
type Middleware struct {
|
|
log *slog.Logger
|
|
params *Params
|
|
trustedProxies []netip.Prefix
|
|
}
|
|
|
|
// New creates a Middleware instance.
|
|
func New(
|
|
_ fx.Lifecycle,
|
|
params Params,
|
|
) (*Middleware, error) {
|
|
trusted, err := ParseTrustedProxies(params.Config.TrustedProxies)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := new(Middleware)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
s.trustedProxies = trusted
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// ParseTrustedProxies converts the TRUSTED_PROXIES entries into
|
|
// prefixes, failing fast on any malformed entry. Each entry must be
|
|
// a CIDR; a lone address is refused. "netwatch-server check-cidr"
|
|
// runs it too.
|
|
func ParseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
|
|
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
|
|
|
for _, cidr := range cidrs {
|
|
prefix, err := netip.ParsePrefix(cidr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"TRUSTED_PROXIES %q: %w", cidr, err,
|
|
)
|
|
}
|
|
|
|
prefixes = append(prefixes, prefix.Masked())
|
|
}
|
|
|
|
return prefixes, nil
|
|
}
|
|
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
|
|
statusCode int
|
|
}
|
|
|
|
func newLoggingResponseWriter(
|
|
w http.ResponseWriter,
|
|
) *loggingResponseWriter {
|
|
return &loggingResponseWriter{w, http.StatusOK}
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|
lrw.statusCode = code
|
|
lrw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func ipFromHostPort(hostPort string) string {
|
|
host, _, err := net.SplitHostPort(hostPort)
|
|
if err != nil {
|
|
return hostPort
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
// clientIP resolves the caller's address. X-Forwarded-For and
|
|
// X-Real-IP are honoured only when the direct peer is a
|
|
// trusted proxy; otherwise the direct peer is returned so a
|
|
// spoofed header cannot forge the logged address.
|
|
func clientIP(
|
|
remoteAddr string,
|
|
header http.Header,
|
|
trusted []netip.Prefix,
|
|
) string {
|
|
peer := ipFromHostPort(remoteAddr)
|
|
|
|
if !addrInAny(peer, trusted) {
|
|
return peer
|
|
}
|
|
|
|
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
|
|
return xff
|
|
}
|
|
|
|
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
|
|
return xr
|
|
}
|
|
|
|
return peer
|
|
}
|
|
|
|
// firstForwardedFor returns the left-most valid address in an
|
|
// X-Forwarded-For list (the original client), or "" if none.
|
|
func firstForwardedFor(value string) string {
|
|
for part := range strings.SplitSeq(value, ",") {
|
|
candidate := strings.TrimSpace(part)
|
|
if validIP(candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func validIP(s string) bool {
|
|
_, err := netip.ParseAddr(s)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// addrInAny reports whether s parses as an address contained
|
|
// in any of the trusted prefixes.
|
|
func addrInAny(s string, trusted []netip.Prefix) bool {
|
|
addr, err := netip.ParseAddr(s)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
addr = addr.Unmap()
|
|
|
|
for _, prefix := range trusted {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Logging returns middleware that logs each request with
|
|
// timing, status code, and client information. Every string
|
|
// taken from the request is cut to logger.MaxLoggedFieldBytes,
|
|
// including the request ID, which chi takes from the client's
|
|
// X-Request-Id header when one is sent.
|
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now().UTC()
|
|
lrw := newLoggingResponseWriter(w)
|
|
ctx := r.Context()
|
|
|
|
defer func() {
|
|
latency := time.Since(start)
|
|
s.log.InfoContext(ctx, "request",
|
|
"request_start", start,
|
|
"method", logger.BoundedForLog(r.Method),
|
|
"url", logger.BoundedForLog(r.URL.String()),
|
|
"useragent", logger.BoundedForLog(r.UserAgent()),
|
|
"request_id",
|
|
logger.BoundedForLog(middleware.GetReqID(ctx)),
|
|
"referer", logger.BoundedForLog(r.Referer()),
|
|
"proto", logger.BoundedForLog(r.Proto),
|
|
"remote_ip",
|
|
logger.BoundedForLog(clientIP(
|
|
r.RemoteAddr,
|
|
r.Header,
|
|
s.trustedProxies,
|
|
)),
|
|
"status", lrw.statusCode,
|
|
"latency_ms",
|
|
latency.Milliseconds(),
|
|
)
|
|
}()
|
|
|
|
next.ServeHTTP(lrw, r)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// SecurityHeaders returns middleware that sets response
|
|
// security headers. It runs before CORS so the headers are
|
|
// present on preflight responses the CORS handler writes.
|
|
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Strict-Transport-Security", hstsValue)
|
|
h.Set("Content-Security-Policy", cspValue)
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("Permissions-Policy", permissionsPolicyValue)
|
|
|
|
next.ServeHTTP(w, r)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// writeJSONError writes the shared JSON error body with the
|
|
// given status. Used where middleware must reject a request
|
|
// before it reaches a handler.
|
|
func writeJSONError(w http.ResponseWriter, status int) {
|
|
w.Header().Set("Content-Type", jsonContentType)
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, jsonErrorBody)
|
|
}
|
|
|
|
// MaxBodyBytes returns middleware that caps the request body at
|
|
// limit bytes. A declared Content-Length over the limit is
|
|
// rejected immediately with 413. Bodies without a declared
|
|
// length (or that understate it) are capped as they are read, so
|
|
// a handler that reads the body sees a *http.MaxBytesError it can
|
|
// map to 413. Mounted again on a route group, it can only lower
|
|
// the limit: a cap applied earlier in the chain still holds.
|
|
func (s *Middleware) MaxBodyBytes(
|
|
limit int64,
|
|
) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.ContentLength > limit {
|
|
writeJSONError(
|
|
w,
|
|
http.StatusRequestEntityTooLarge,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
|
|
|
next.ServeHTTP(w, r)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// Recoverer returns middleware that recovers from a panic in a
|
|
// downstream handler, logs the panic and stack trace through
|
|
// slog, and responds 500 with no body, unless a status was already
|
|
// written, as Write500OnPanic writes one for a handler's panic.
|
|
// http.ErrAbortHandler is re-panicked so the server can abort the
|
|
// response as intended.
|
|
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
defer func() {
|
|
rec := recover()
|
|
if rec == nil {
|
|
return
|
|
}
|
|
|
|
err, ok := rec.(error)
|
|
if ok && errors.Is(err, http.ErrAbortHandler) {
|
|
panic(rec)
|
|
}
|
|
|
|
s.log.ErrorContext(r.Context(),
|
|
"panic recovered",
|
|
"panic", fmt.Sprintf("%v", rec),
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
|
|
if ww.Status() == 0 {
|
|
ww.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
next.ServeHTTP(ww, r)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// Write500OnPanic returns middleware that writes 500 when a downstream
|
|
// handler panics before writing a status, and lets the panic go on to
|
|
// Sentry and Recoverer. Recoverer, which comes first, would write its
|
|
// 500 only after the request log and the metrics had recorded the
|
|
// status they saw; placed after each of them, this makes them record
|
|
// the 500.
|
|
func (s *Middleware) Write500OnPanic() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
defer func() {
|
|
rec := recover()
|
|
if rec == nil {
|
|
return
|
|
}
|
|
|
|
if ww.Status() == 0 {
|
|
ww.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
panic(rec)
|
|
}()
|
|
|
|
next.ServeHTTP(ww, r)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// CORS returns middleware that lets pages served from the given
|
|
// origins call the API. With no origins it adds no CORS headers at
|
|
// all, so only same-origin pages can use the API. That case must not
|
|
// reach cors.Handler, which treats an empty origin list as "allow
|
|
// every origin".
|
|
func (s *Middleware) CORS(
|
|
origins []string,
|
|
) func(http.Handler) http.Handler {
|
|
if len(origins) == 0 {
|
|
return func(next http.Handler) http.Handler { return next }
|
|
}
|
|
|
|
return cors.Handler(cors.Options{
|
|
AllowedOrigins: origins,
|
|
AllowedMethods: []string{http.MethodGet, http.MethodPost},
|
|
AllowedHeaders: []string{"Content-Type"},
|
|
AllowCredentials: false,
|
|
MaxAge: corsMaxAgeSec,
|
|
})
|
|
}
|
|
|
|
// RateLimit returns middleware that allows each client address
|
|
// perMinute requests a minute and answers the rest with 429, the
|
|
// Retry-After header httprate sets, and the usual error body. The
|
|
// address is the one clientIP resolves, so clients behind the reverse
|
|
// proxy are limited one by one, not together as the proxy.
|
|
func (s *Middleware) RateLimit(
|
|
perMinute int,
|
|
) func(http.Handler) http.Handler {
|
|
return httprate.LimitBy(perMinute, time.Minute,
|
|
func(r *http.Request) (string, error) {
|
|
return clientIP(r.RemoteAddr, r.Header, s.trustedProxies), nil
|
|
},
|
|
httprate.WithLimitHandler(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSONError(w, http.StatusTooManyRequests)
|
|
},
|
|
),
|
|
)
|
|
}
|
|
|
|
// Metrics returns middleware that records each request's duration and
|
|
// response size, and the requests in progress, in registry. They are
|
|
// labelled by the request path.
|
|
func (s *Middleware) Metrics(
|
|
registry prometheus.Registerer,
|
|
) func(http.Handler) http.Handler {
|
|
mdlw := ghmm.New(ghmm.Config{
|
|
Recorder: metrics.NewRecorder(metrics.Config{Registry: registry}),
|
|
})
|
|
|
|
return std.HandlerProvider("", mdlw)
|
|
}
|
|
|
|
// MetricsAuth returns middleware that lets a request through only with
|
|
// METRICS_USERNAME and METRICS_PASSWORD as its basic auth credentials,
|
|
// and answers any other with 401.
|
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|
return basicauth.New("metrics", map[string][]string{
|
|
s.params.Config.MetricsUsername: {s.params.Config.MetricsPassword},
|
|
})
|
|
}
|