117 lines
4.4 KiB
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()))
|
|
Fail(w, r, CodeInternalError)
|
|
}
|
|
}()
|
|
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 }
|