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