// Package middleware provides HTTP middleware for logging, auth, // CORS, and metrics. package middleware import ( "log/slog" "net" "net/http" "time" basicauth "github.com/99designs/basicauth-go" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/go-chi/cors" 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" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" ) const ( // corsMaxAge is the maximum time (in seconds) that a // preflight response can be cached. corsMaxAge = 300 // unmatchedRoute is logged in the access log's url field when a // redirected or rejected request matched no route pattern at // all. Every byte of such a path is client-chosen, so none of it // is logged. unmatchedRoute = "(unmatched)" // redactedQuery stands in for the query string on the access log // branches that keep the concrete URL. The query is client-chosen // on every route, including the ones that answer an // unauthenticated 200, so logging it verbatim would let a client // pick the size of the line it writes. redactedQuery = "?(redacted)" // maxLogRequestIDBytes bounds the request id, which is also // client-supplied: chi's RequestID middleware passes an inbound // X-Request-Id header through verbatim. Its generated form is an // order of magnitude shorter than this. maxLogRequestIDBytes = 128 // maxLogMethodBytes bounds the method. Go accepts any RFC 7230 // token there, bounded only by the header size limit, so it is // client-chosen text like the rest. The longest registered method // is half this. maxLogMethodBytes = 32 // MaxAccessLogLineBytes is the ceiling on one JSON access log line, // and the number an operator multiplies by the request rate to size // log storage. It is not an observation of a sample: it is the sum // of the budgets above, each of which logfield.Truncate enforces in // ENCODED bytes, plus the part of the line no client can influence. // // url, useragent, referer 3*(512+11) = 1569 // request_id 128+11 = 139 // method 32+11 = 43 // fixed portion = 336 // ---- // 2087 // // The fixed portion is the JSON punctuation, the field names, the // level and the message, both timestamps at their longest, an IPv6 // remoteIP with a zone, a three-digit status and a full-width int64 // latency. Stated at 2560 so the figure carries headroom rather // than sitting on the arithmetic. // // The tty text handler in internal/logger is covered by the same // figure. logfield.EncodedBytes charges every rune at least what // the wider of the two handlers emits for it — including the ten // bytes strconv.Quote spends on a non-printable rune at or above // U+10000, which is four more than the JSON handler ever spends — // so each budget bounds the encoded field under either handler. // The text handler's fixed portion is 286, the smaller of the two, // which puts its worst case at 2037. // // It is also the ceiling on every OTHER line this service writes // THROUGH SLOG that carries a client-supplied value. Those lines — // the MaxBodySize rejection, the CSRF rejection, the rate-limit // rejection, the unauthenticated-request and unknown-entrypoint // DEBUG lines, and the failed-login DEBUG lines — spend the same // per-field budgets, and each carries strictly fewer // client-supplied fields than the access log does, so none of them // can reach a width the access log cannot. That is asserted // directly, per line and under both handlers, rather than left to // the reasoning: see logbound_test.go in this package and in // internal/handlers. // // Two writers it does NOT cover, so that the figure above is not // read as more than it is: // // - The "log" delivery target, which exists to write the whole // inbound event to the log. Deliberate; see // internal/delivery/target_log.go. // - GORM's default logger, which prints the interpolated SQL to // stdout on a record-not-found and so is unbounded on the // receiver and login lookups. NOT deliberate; filed as // https://git.eeqj.de/sneak/webhooker/issues/178. MaxAccessLogLineBytes = 2560 ) //nolint:revive // MiddlewareParams is a standard fx naming convention. type MiddlewareParams struct { fx.In Logger *logger.Logger Globals *globals.Globals Config *config.Config Session *session.Session } // Middleware provides HTTP middleware for logging, CORS, auth, and // metrics. type Middleware struct { log *slog.Logger params *MiddlewareParams session *session.Session } // New creates a Middleware from the provided fx parameters. // //nolint:revive // lc parameter is required by fx even if unused. func New( lc fx.Lifecycle, params MiddlewareParams, ) (*Middleware, error) { s := new(Middleware) s.params = ¶ms s.log = params.Logger.Get() s.session = params.Session return s, nil } // the following is from // https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/ func ipFromHostPort(hp string) string { h, _, err := net.SplitHostPort(hp) if err != nil { return "" } if len(h) > 0 && h[0] == '[' { return h[1 : len(h)-1] } return h } type loggingResponseWriter struct { http.ResponseWriter statusCode int } // newLoggingResponseWriter wraps w and records status codes. func newLoggingResponseWriter( w http.ResponseWriter, ) *loggingResponseWriter { return &loggingResponseWriter{w, http.StatusOK} } func (lrw *loggingResponseWriter) WriteHeader(code int) { lrw.statusCode = code lrw.ResponseWriter.WriteHeader(code) } // concreteLogURL renders the request's own URL for the access log // branches that keep it, with the query string replaced by a fixed // marker. // // The path on those branches is bounded by the service's routes or by // the operator's data — a 2xx on the receiver means the UUID named a // stored entrypoint, a 2xx under /s means the file is in the embedded // tree. The query is not bounded by anything: /.well-known/healthcheck // and /s/* take no authentication and sit behind no rate limiter, and // /pages/login behind only the login limiter, so any of them will // answer 200 to a URL carrying an arbitrary number of arbitrary bytes // after the '?'. Keeping the path and dropping the query is what makes // this branch as bounded as the pattern branches below. // // Nothing debuggable is lost. One route in the service reads a query // parameter at all — `page`, on the authenticated pagination links in // internal/handlers/source_management.go — and the alternatives that // would preserve more (a key count, a key allowlist) all require // parsing an attacker-sized query on every request, which is work an // unauthenticated client would then be choosing for us. func concreteLogURL(r *http.Request) string { path := r.URL.EscapedPath() if r.URL.RawQuery == "" && !r.URL.ForceQuery { return path } return path + redactedQuery } // accessLogURL returns the value for the access log's url field. // // 2xx and 5xx responses get the concrete path (see concreteLogURL). A // success resolved against a static route or against the operator's // own data — on the receiver, a 2xx means the UUID named a stored // entrypoint — and a server error is our own bug, where the exact URL // is the primary evidence and which no client can provoke at will. // // 3xx and 4xx responses get the chi route pattern instead. Those are // the outcomes an unauthenticated client drives for free: 404 or 429 // on any invented /webhook/ path, 303 to the login page on any // invented /user/ path. Logging the concrete URL there lets a flood // write attacker-chosen text, of attacker-chosen length, into the // operator's log at one line per request. The pattern comes from the // router's own table, so it is bounded by the service's routes while // still naming which class of request was rejected. // // The pattern is only populated once routing has run, so this must be // called after the handler returns, not before. func accessLogURL(r *http.Request, status int) string { if status < http.StatusMultipleChoices || status >= http.StatusInternalServerError { return concreteLogURL(r) } if rc := chi.RouteContext(r.Context()); rc != nil { if pattern := rc.RoutePattern(); pattern != "" { return pattern } } return unmatchedRoute } // Logging returns middleware that logs each HTTP request with // timing and metadata. 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() lrw := newLoggingResponseWriter(w) ctx := r.Context() defer func() { latency := time.Since(start) requestID := "" if reqID := ctx.Value( middleware.RequestIDKey, ); reqID != nil { if id, ok := reqID.(string); ok { requestID = id } } // Every field below that a client can influence is // truncated to a fixed budget, so the size of this // line does not track the size of the request. s.log.Info("http request", "request_start", start, "method", logfield.Truncate( r.Method, maxLogMethodBytes, ), "url", logfield.Truncate( accessLogURL(r, lrw.statusCode), logfield.MaxBytes, ), "useragent", logfield.Truncate( r.UserAgent(), logfield.MaxBytes, ), "request_id", logfield.Truncate( requestID, maxLogRequestIDBytes, ), "referer", logfield.Truncate( r.Referer(), logfield.MaxBytes, ), "proto", r.Proto, "remoteIP", ipFromHostPort(r.RemoteAddr), "status", lrw.statusCode, "latency_ms", latency.Milliseconds(), ) }() next.ServeHTTP(lrw, r) }) } } // CORS returns middleware that sets CORS headers (permissive in // dev, no-op in prod). func (s *Middleware) CORS() func(http.Handler) http.Handler { if s.params.Config.IsDev() { // In development, allow any origin for local testing. return cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{ "GET", "POST", "PUT", "DELETE", "OPTIONS", }, AllowedHeaders: []string{ "Accept", "Authorization", "Content-Type", "X-CSRF-Token", }, ExposedHeaders: []string{"Link"}, AllowCredentials: false, MaxAge: corsMaxAge, }) } // In production, the web UI is server-rendered so // cross-origin requests are not expected. Return a no-op // middleware. return func(next http.Handler) http.Handler { return next } } // RequireAuth returns middleware that checks for a valid session. // Unauthenticated users are redirected to the login page. func (s *Middleware) RequireAuth() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { sess, err := s.session.Get(r) if err != nil { s.log.Debug( "auth middleware: failed to get session", "error", err, ) http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } // IsAuthenticated also enforces both session expiry // deadlines, so an idle-expired or absolutely-expired // session lands here and is sent back to the login // page. if !s.session.IsAuthenticated(sess) { // This is the unauthenticated branch, so both // fields are entirely client-chosen and neither // is bounded by anything the router did. DEBUG // is off by default, but turning it on to // diagnose a problem must not hand a client an // unbounded write into the log, so the same // budgets apply here as in the access log. s.log.Debug( "auth middleware: unauthenticated request", "path", logfield.Truncate( r.URL.Path, logfield.MaxBytes, ), "method", logfield.Truncate( r.Method, maxLogMethodBytes, ), ) http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } // This request authenticated with the session, so it // counts as activity: push the idle deadline forward. // This is the only place sessions are refreshed, which // is what keeps an unauthenticated request from // extending someone else's session. Touch advances the // idle clock only -- the absolute cap is untouched -- // and reports false when nothing changed, so most // requests do not re-issue the cookie. Save before the // handler runs, while the headers are still ours to // write. if s.session.Touch(sess) { saveErr := s.session.Save(r, w, sess) if saveErr != nil { s.log.Error( "auth middleware: failed to refresh session", "error", saveErr, ) } } next.ServeHTTP(w, r) }) } } // Metrics returns middleware that records Prometheus HTTP metrics. func (s *Middleware) Metrics() func(http.Handler) http.Handler { mdlw := ghmm.New(ghmm.Config{ Recorder: metrics.NewRecorder(metrics.Config{}), }) return func(next http.Handler) http.Handler { return std.Handler("", mdlw, next) } } // MetricsAuth returns middleware that protects metrics endpoints // with basic auth. func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { return basicauth.New( "metrics", map[string][]string{ s.params.Config.MetricsUsername: { s.params.Config.MetricsPassword, }, }, ) } // SecurityHeaders returns middleware that sets production security // headers on every response: HSTS, X-Content-Type-Options, // X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy. 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, ) { w.Header().Set( "Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload", ) w.Header().Set( "X-Content-Type-Options", "nosniff", ) w.Header().Set("X-Frame-Options", "DENY") w.Header().Set( "Content-Security-Policy", "default-src 'self'; "+ "script-src 'self' 'unsafe-inline'; "+ "style-src 'self' 'unsafe-inline'", ) w.Header().Set( "Referrer-Policy", "strict-origin-when-cross-origin", ) w.Header().Set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()", ) next.ServeHTTP(w, r) }) } } // NoCache returns middleware that instructs browsers and // intermediary proxies not to cache the response. It sets // Cache-Control: no-store and Pragma: no-cache (the latter for // older HTTP/1.0 intermediaries). Apply it to authenticated pages // so webhook configuration and captured event data are not stored // by caches. func (s *Middleware) NoCache() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") next.ServeHTTP(w, r) }) } } // bodyLimitedMethod reports whether the request method carries a // body that the MaxBodySize middleware should cap. func bodyLimitedMethod(method string) bool { return method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch } // MaxBodySize returns middleware that limits the size of // POST/PUT/PATCH request bodies to maxBytes. It must be registered // before any middleware that parses the body — notably CSRF, which // calls r.PostFormValue — so that form parsing happens under this // cap rather than net/http's 10 MB default. // // Two enforcement paths exist, because http.MaxBytesReader alone // cannot produce a 413: it reports the overflow as an error from // Read, by which point the body parser downstream has already // converted that error into its own response. // // - Declared oversize: the request announces a Content-Length // greater than maxBytes. The middleware answers 413 Request // Entity Too Large immediately and does not call the next // handler, so neither CSRF nor the endpoint handler runs. // - Undeclared oversize: the request is chunked (Content-Length // of -1) or lies about its Content-Length. There is nothing to // check up front, so http.MaxBytesReader hard-caps the body at // maxBytes and the request fails downstream — the form parse // errors out and CSRF rejects it with 403. The response is less // precise than a 413, but the body is still never buffered // beyond the cap, which is the property that matters. func (s *Middleware) MaxBodySize( maxBytes int64, ) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { if !bodyLimitedMethod(r.Method) { next.ServeHTTP(w, r) return } if r.ContentLength > maxBytes { // This runs ahead of RequireAuth (see // setupUserRoutes and friends in // internal/server/routes.go), so an // unauthenticated client reaches it with a path // of its own choosing and its own length — // POST /source/<8 KB>/edit with an oversize // declared Content-Length costs nothing to // send. At WARN, on by default, that is a // write into the operator's log sized by the // attacker unless the path is capped. Same // budgets as the access log, so this line // cannot be wider than that one. s.log.Warn( "request body exceeds limit", "method", logfield.Truncate( r.Method, maxLogMethodBytes, ), "path", logfield.Truncate( r.URL.Path, logfield.MaxBytes, ), "content_length", r.ContentLength, "limit", maxBytes, ) http.Error( w, "Request Entity Too Large", http.StatusRequestEntityTooLarge, ) return } r.Body = http.MaxBytesReader(w, r.Body, maxBytes) next.ServeHTTP(w, r) }) } }