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 }