textmachine/platform/internal/httpapi/middleware.go

117 lines
4.4 KiB
Go

package httpapi
import (
"log/slog"
"net/http"
"runtime/debug"
"time"
)
// DefaultMaxBody caps a request body on the versioned surface. Every contract route today carries
// JSON of a few kilobytes; the route that will carry a book file registers its own, larger limit
// rather than raising this one for everybody.
const DefaultMaxBody = 1 << 20
// LimitBody puts an http.MaxBytesReader on every request of the subtree it wraps. Applied here
// rather than per handler because a handler added later would not know the rule (ASVS input
// limits; the second half of PD-2).
func LimitBody(n 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.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, n)
}
next.ServeHTTP(w, r)
})
}
}
// SecurityHeaders applies the product invariants to every response, here rather than per handler
// because a handler added later would not know the rule.
//
// PT-34: not one byte of a user's translation may reach an indexable URL.
func SecurityHeaders(hsts bool) 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("X-Robots-Tag", "noindex, nofollow")
h.Set("Cache-Control", "no-store")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
// This service answers with JSON and redirects, never with a document worth embedding.
// frame-ancestors is what stops /auth/* being framed; X-Frame-Options is its ancestor
// for clients that predate CSP.
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
h.Set("X-Frame-Options", "DENY")
if hsts {
// The __Host- prefix protects the WRITE of a cookie, not the first navigation:
// without HSTS a plain-http first request is downgradable. Off in the dev profile,
// where a pinned https policy for localhost would be a lasting mistake.
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
}
// Recover turns a panic into a 500 instead of a dropped connection.
func Recover(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
// route, not r.URL.Path (PD-3).
log.ErrorContext(r.Context(), "panic in handler",
"panic", v, "method", r.Method, "route", routeOf(r),
// Without the stack, "panic: runtime error" plus a route is not a lead.
"stack", string(debug.Stack()))
WriteProblem(w, http.StatusInternalServerError, "Internal error", "")
}
}()
next.ServeHTTP(w, r)
})
}
}
// AccessLog writes one INFO line per request. Deliberately absent: money (D39.84 and the norm of
// the P0 prompt — costs do not reach INFO), request bodies and any user text. The request id is
// added by the log handler (reqid.WithContext), not by hand.
func AccessLog(log *slog.Logger) 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()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.InfoContext(r.Context(), "request",
"method", r.Method,
// Pattern, not the raw path: the raw one carries book and run ids, which multiply
// log cardinality and identify a user's library in an operator's index. ServeMux
// fills Pattern in place, so it is readable here even though the mux ran inside.
"route", routeOf(r),
"status", rec.status,
"ms", time.Since(start).Milliseconds())
})
}
}
func routeOf(r *http.Request) string {
if p := r.Pattern; p != "" {
return p
}
return "(unmatched)"
}
// statusRecorder captures the status code. Unwrap keeps http.ResponseController working through
// the wrapper — that is how an SSE handler reaches Flush. NOT how it clears a read deadline: doing
// that by hand re-creates PD-2 on a half-fed request and is forbidden (STACK_DECISIONS §12).
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }