package api import ( "bytes" "context" "crypto/rand" "encoding/hex" "io" "log/slog" "net/http" "runtime/debug" "strconv" "strings" "time" "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/observability" ) type requestIDKey struct{} // RequestID returns the id assigned to the request. func RequestID(r *http.Request) string { id, _ := r.Context().Value(requestIDKey{}).(string) return id } func logger(r *http.Request) *slog.Logger { return observability.Logger(r.Context()) } // chain applies middleware in the order given (first is outermost). func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler { for i := len(mws) - 1; i >= 0; i-- { h = mws[i](h) } return h } func (s *Server) requestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var b [8]byte rand.Read(b[:]) //nolint:errcheck id := hex.EncodeToString(b[:]) ip := auth.ClientIP(r, false) ctx := context.WithValue(r.Context(), requestIDKey{}, id) ctx = observability.WithLogger(ctx, s.deps.Log.With("request_id", id)) ctx = audit.WithIP(ctx, ip) w.Header().Set("X-Request-ID", id) next.ServeHTTP(w, r.WithContext(ctx)) }) } type statusWriter struct { http.ResponseWriter status int } func (w *statusWriter) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) } func (w *statusWriter) Write(b []byte) (int, error) { if w.status == 0 { w.status = 200 } return w.ResponseWriter.Write(b) } // Flush lets SSE handlers flush through the wrapper. func (w *statusWriter) Flush() { if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } } func (s *Server) logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w} next.ServeHTTP(sw, r) route := r.Pattern if route == "" { route = "unmatched" } d := time.Since(start) s.deps.Metrics.HTTPRequests.WithLabelValues(route, strconv.Itoa(sw.status)).Inc() s.deps.Metrics.HTTPDuration.WithLabelValues(route).Observe(d.Seconds()) logger(r).Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "ms", d.Milliseconds()) }) } func (s *Server) recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { if rec == http.ErrAbortHandler { panic(rec) } logger(r).Error("handler panicked", "panic", rec, "stack", string(debug.Stack())) writeError(w, r, http.StatusInternalServerError, "internal", "internal error") } }() next.ServeHTTP(w, r) }) } // securityHeaders sets a strict CSP (no inline scripts, self only) and the // usual hardening headers (T10). func (s *Server) securityHeaders(next http.Handler) http.Handler { csp := "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", csp) h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "same-origin") h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") if s.deps.Config.Production() { h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } // bodyLimit bounds request bodies and refuses NUL bytes anywhere in the // request. PostgreSQL text cannot hold NUL, so a NUL that reached a query // would surface as an internal error; refusing it at the edge keeps the // answer a deliberate 400 and keeps error paths out of reach of a caller // probing for them. Bodies are buffered up to the limit, which the JSON and // form decoders would do anyway. func (s *Server) bodyLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "\x00") || strings.Contains(r.URL.RawPath, "%00") || strings.Contains(r.URL.RawQuery, "%00") || strings.Contains(r.URL.RawQuery, "\x00") { writeError(w, r, http.StatusBadRequest, "invalid_input", "NUL bytes are not accepted") return } if r.Body != nil && r.Body != http.NoBody { r.Body = http.MaxBytesReader(w, r.Body, s.deps.Config.MaxRequestBody) b, err := io.ReadAll(r.Body) if err != nil { writeError(w, r, http.StatusRequestEntityTooLarge, "too_large", "request body too large") return } // A raw NUL, a JSON-escaped one or a form-encoded one all decode to the // same byte the database refuses. if bytes.IndexByte(b, 0) >= 0 || bytes.Contains(b, []byte(`\u0000`)) || bytes.Contains(b, []byte("%00")) { writeError(w, r, http.StatusBadRequest, "invalid_input", "NUL bytes are not accepted") return } r.Body = io.NopCloser(bytes.NewReader(b)) r.ContentLength = int64(len(b)) } next.ServeHTTP(w, r) }) } // apiAuthFail is the RequireRole failure handler for JSON routes. func apiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) { if authenticated { writeError(w, r, http.StatusForbidden, "forbidden", "insufficient role") return } writeError(w, r, http.StatusUnauthorized, "unauthenticated", "authentication required") } // require wraps a handler with a role check for API routes. func require(min auth.Role, h http.HandlerFunc) http.Handler { return auth.RequireRole(min, apiAuthFail)(h) }