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
513 lines
17 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|