initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
513
internal/httpapi/middleware.go
Normal file
513
internal/httpapi/middleware.go
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue