go-bananas/internal/httpapi/middleware.go
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

513 lines
17 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// contextKey is a private key type so that nothing outside this package can
// collide with a context key by accident.
type contextKey int
const (
ctxKeyIdentity contextKey = iota
ctxKeyRequestID
)
// identityFrom returns the authenticated caller stored on the context.
func identityFrom(ctx context.Context) (*domain.Identity, error) {
identity, ok := ctx.Value(ctxKeyIdentity).(*domain.Identity)
if !ok || identity == nil {
return nil, domain.ErrUnauthorized
}
return identity, nil
}
// mustIdentity returns the caller, falling back to an anonymous viewer. It is
// used only on routes where authentication has already been enforced, so a nil
// result indicates a programming error rather than an attack.
func mustIdentity(ctx context.Context) *domain.Identity {
identity, err := identityFrom(ctx)
if err != nil {
return &domain.Identity{Subject: "anonymous", Roles: []domain.Role{domain.RoleViewer}, Method: "internal"}
}
return identity
}
// requestIDFrom returns the correlation id stored on the context.
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID).(string)
return id
}
// actorFrom renders the caller for audit rows.
func actorFrom(ctx context.Context) (subject, email string) {
identity, err := identityFrom(ctx)
if err != nil {
return "anonymous", ""
}
return identity.Subject, identity.Email
}
// marshalJSON encodes a payload, adding a newline for terminal friendliness.
func marshalJSON(payload any) ([]byte, error) {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
return append(encoded, '\n'), nil
}
// ------------------------------------------------------------- request id --
// RequestIDMiddleware assigns a correlation id to every request.
//
// An inbound X-Request-ID is honoured when present, because a trace that starts
// in another service should stay connected; otherwise a fresh one is generated.
// The value is echoed on the response so a user can quote it in a bug report.
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if id == "" || len(id) > 128 || !printableASCII(id) {
id = newRequestID()
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
})
}
// printableASCII rejects a header value that could smuggle a newline into logs.
func printableASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < 0x21 || s[i] > 0x7e {
return false
}
}
return true
}
// newRequestID returns a short unique id.
func newRequestID() string {
if id, err := randomHex(8); err == nil {
return id
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// ------------------------------------------------------------------ auth --
// AuthMiddleware resolves the caller and stores the identity on the context.
//
// It never rejects a request itself. Public endpoints and the admin API apply
// their own policy, which keeps this middleware a single, uniform
// authentication step rather than a policy decision scattered across routes.
func AuthMiddleware(authenticator *auth.Authenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := authenticator.Authenticate(r)
if err != nil || identity == nil {
// An anonymous identity rather than a hard failure, so that public
// endpoints can serve and protected endpoints can respond 401 with
// a proper body instead of a bare net/http rejection.
identity = &domain.Identity{
Subject: "anonymous",
Name: "anonymous",
Roles: []domain.Role{domain.RoleViewer},
Method: "anonymous",
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyIdentity, identity)))
})
}
}
// RequireAuthMiddleware rejects anonymous callers. It is applied to the admin
// API as a whole so that forgetting a per-route check cannot expose an endpoint.
func RequireAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
if err != nil || identity == nil || identity.Method == "anonymous" {
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"authentication is required for this endpoint", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// ------------------------------------------------------------ security --
// SecurityHeadersMiddleware sets the response headers that apply to every route.
func SecurityHeadersMiddleware(cfg config.Config) func(http.Handler) http.Handler {
// The admin UI is a self-contained SPA with no third-party scripts, no inline
// event handlers and no remote fonts, so the strictest workable policy can be
// applied. 'unsafe-inline' for styles is required because the theme editor
// injects a live preview stylesheet.
csp := strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"font-src 'self' data:",
"form-action 'self'",
"frame-ancestors 'none'",
"img-src 'self' data: blob:",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
}, "; ")
// HSTS is only advertised when the deployment is actually reachable over TLS.
// Sending it over plain HTTP is harmless for browsers that ignore it, but it is
// wrong for a development deployment served on localhost, where it would pin
// the developer's own browser to HTTPS for a server that does not speak it.
hsts := ""
if strings.HasPrefix(cfg.PublicBaseURL, "https://") {
hsts = "max-age=31536000; includeSubDomains"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()")
h.Set("Content-Security-Policy", csp)
if hsts != "" {
h.Set("Strict-Transport-Security", hsts)
}
next.ServeHTTP(w, r)
})
}
}
// isRuntimePath reports whether a request is for the public runtime tree.
//
// That tree is deliberately readable from any origin: it serves the styling contract
// and the SDK, which every consuming application loads from its own host, so restricting
// it by origin would defeat the only purpose it serves. The admin API below /api/v1 is
// the opposite case — it is per-user and credentialed, so it stays on the allow-list.
func isRuntimePath(path string) bool {
return path == "/rt/v1" || strings.HasPrefix(path, "/rt/v1/")
}
// CORSMiddleware answers preflight requests and adds the response headers.
//
// The runtime API is deliberately not CORS-restricted: it serves public, cacheable
// assets that a browser on any origin is expected to load, exactly as a CDN would.
// The admin API is restricted to the configured origins.
//
// An empty allow-list used to mean that the runtime was closed to every origin, which
// contradicted the intent recorded here and failed in production while every test
// passed: the check loaded the SDK from a local copy rather than from the service. So
// "not configured" now means "open" for the runtime tree, and "not configured" still
// means "closed" for the admin API, where the default has to be the safe one.
func CORSMiddleware(cfg config.Config) func(http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(cfg.CORSAllowedOrigins))
wildcard := false
for _, origin := range cfg.CORSAllowedOrigins {
if origin == "*" {
wildcard = true
}
allowed[origin] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
_, isAllowed := allowed[origin]
switch {
case isAllowed:
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
case wildcard || isRuntimePath(r.URL.Path):
// `*` and credentials together are rejected by browsers, so this branch
// deliberately does not set Allow-Credentials: the runtime needs none,
// and the SDK sends credentials:'omit' precisely so it stays unreadable
// as a credentialed call.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Add("Vary", "Origin")
}
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
w.Header().Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// TrustedProxyMiddleware resolves the client address, honouring forwarding
// headers only from configured proxies.
//
// Without an allow-list, any client could set X-Forwarded-For and spoof its
// address in the audit log, so an unconfigured deployment ignores the headers
// entirely and records the direct peer.
func TrustedProxyMiddleware(cfg config.Config) func(http.Handler) http.Handler {
nets := make([]*net.IPNet, 0, len(cfg.TrustedProxies))
for _, entry := range cfg.TrustedProxies {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if _, network, err := net.ParseCIDR(entry); err == nil {
nets = append(nets, network)
continue
}
if ip := net.ParseIP(entry); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
}
trusted := func(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, network := range nets {
if network.Contains(ip) {
return true
}
}
return false
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
addr := r.RemoteAddr
if trusted(r.RemoteAddr) {
if forwarded := clientFromForwardedFor(r.Header.Get("X-Forwarded-For")); forwarded != "" {
addr = forwarded
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, ctxKeyClientIP, addr)))
})
}
}
// ctxKeyClientIP stores the resolved client address.
var ctxKeyClientIP contextKey = 100
// clientFromForwardedFor returns the left-most entry of an X-Forwarded-For
// header, which is the original client when every proxy in the chain appends.
func clientFromForwardedFor(header string) string {
if header == "" {
return ""
}
first, _, _ := strings.Cut(header, ",")
return strings.TrimSpace(first)
}
// clientIPFrom returns the resolved client address.
func clientIPFrom(ctx context.Context) string {
addr, _ := ctx.Value(ctxKeyClientIP).(string)
if addr == "" {
return ""
}
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// ------------------------------------------------------------- rate limit --
// RateLimiter is a fixed-window counter keyed by client address and route class.
//
// A fixed window is chosen over a sliding log deliberately: it needs one counter
// per client rather than a list of timestamps, so its memory is O(clients) and it
// cannot be made to degrade by a client sending requests at a high rate. The
// tradeoff is a burst of up to 2x the limit across a window boundary, which is
// acceptable for the abuse it defends against.
type RateLimiter struct {
limit int
window time.Duration
mu sync.Mutex
hits map[string]*counter
}
// counter is one client's window.
type counter struct {
count int
reset time.Time
}
// NewRateLimiter builds a limiter. A limit of zero disables rate limiting.
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
if window <= 0 {
window = time.Minute
}
limiter := &RateLimiter{
limit: limit,
window: window,
hits: make(map[string]*counter),
}
go limiter.reap()
return limiter
}
// reap evicts expired counters so a long-running process does not accumulate one
// entry per address it has ever seen.
func (l *RateLimiter) reap() {
ticker := time.NewTicker(l.window)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
l.mu.Lock()
for key, c := range l.hits {
if now.After(c.reset) {
delete(l.hits, key)
}
}
l.mu.Unlock()
}
}
// Allow records a hit and reports whether it is within the limit.
func (l *RateLimiter) Allow(key string) bool {
if l.limit <= 0 {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.hits[key]
if !ok || now.After(c.reset) {
l.hits[key] = &counter{count: 1, reset: now.Add(l.window)}
return true
}
c.count++
return c.count <= l.limit
}
// RetryAfter returns how long a client should wait, in seconds.
func (l *RateLimiter) RetryAfter(key string) int {
l.mu.Lock()
defer l.mu.Unlock()
if c, ok := l.hits[key]; ok {
remaining := time.Until(c.reset)
if remaining > 0 {
return int(remaining.Seconds()) + 1
}
}
return int(l.window.Seconds()) + 1
}
// RateLimitMiddleware applies a limiter keyed by client address.
func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + routeClass(r.URL.Path)
if !limiter.Allow(key + " " + clientIPFrom(r.Context())) {
w.Header().Set("Retry-After", itoa(limiter.RetryAfter(key+" "+clientIPFrom(r.Context()))))
writeError(w, r, http.StatusTooManyRequests, CodeRateLimited,
"too many requests; retry after the indicated delay", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// routeClass collapses a path to its first two segments so that per-theme
// requests share one bucket instead of letting an attacker allocate an unbounded
// number of buckets by varying the slug.
func routeClass(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) > 2 {
parts = parts[:2]
}
return strings.Join(parts, "/")
}
// --------------------------------------------------------------- recover --
// RecoverMiddleware turns a panic into a 500 without taking the process down.
func RecoverMiddleware(log Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:contextcheck // r carries the request context, and writeError
// and writeJSON both read it from r. The closure spawns no goroutine and
// creates no derived context, so there is nothing for it to pass on. The
// rule fires on every deferred recover closure, which is why the reason is
// recorded here rather than at the call site of writeError.
defer func() {
if recovered := recover(); recovered != nil {
// A broken pipe is a normal consequence of a client navigating
// away, not a server fault, so it is logged at debug level.
log.Error("panic recovered",
"error", recovered,
"method", r.Method,
"path", r.URL.Path,
"requestId", requestIDFrom(r.Context()),
)
writeError(w, r, http.StatusInternalServerError, CodeInternal,
"the server encountered an unexpected condition", nil)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ------------------------------------------------------------ body limits --
// MaxBodyBytes is the largest request body the admin API accepts. A theme
// document is a few kilobytes; this leaves generous headroom while bounding the
// memory a single request can consume.
const MaxBodyBytes = 2 << 20 // 2 MiB
// BodyLimitMiddleware caps request bodies and rejects oversized payloads.
func BodyLimitMiddleware(limit int64) func(http.Handler) http.Handler {
if limit <= 0 {
limit = MaxBodyBytes
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > limit {
writeError(w, r, http.StatusRequestEntityTooLarge, CodePayloadTooBig,
"request body is too large", nil)
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
}