Files
Andy Dixon 59493d9398 fix(api): refuse NUL bytes at the request edge; test SQL injection by effect
The SQL-injection effect test found that a NUL byte in a query string,
path or JSON body reached PostgreSQL and surfaced as a 500. Requests are
now buffered up to the existing limit and NUL in any form (raw, JSON
escaped, form encoded) is refused with a 400 before a query runs.

TestSQLInjectionHasNoEffect sends metacharacter payloads through every
search, filter and free-text route and asserts effect: users, policies,
sessions and tokens unchanged, seeded content intact, payloads stored
verbatim. TestDynamicSQLIdentifiersAreAllowListed scans for statements
assembled with Sprintf outside the reviewed allow-list.
2026-07-19 19:23:27 +01:00

167 lines
5.5 KiB
Go

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)
}