mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs(b3-1): record PR #1449 =71d867cbin the status line, step table and evidence block Pre-squash SHAs completed with the coverage commita0356ee1and the three Codex rounds (head8614603b). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * refactor(b3-2): auth_deps.go — the consumer-owned AuthService interface Eight methods beside the handlers that need them: Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP — fewer than the ten *db.DB methods the two handlers call today. The input and result types they name (Principal, RegisterInput, LoginInput, AuthResult, TOTPChangeResult) and the AuthBroadcaster the delete path needs live in service/auth.go. Nothing implements or calls the interface yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * refactor(b3-2): service.AuthService — the auth orchestration, moved verbatim Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP and the RegistrationPolicy gate two characterization rows pin ahead of the body read. The enumeration guard, the F3 reserve-before-compare, the audit writes, the best-effort custom-status clear and the 200+warning partial-success contract move line for line; persistence stays in db behind Store. Each refusal is a named service.Err* whose Error() is the exact public message the handler wrote and whose category (ErrUnauthorized and ErrInvalidInput join the message.go set) the transport maps to a status. The auth rate multiplier moves to auth/ratescale.go so the route mounts and the login failure accounting read one value; api keeps its wrappers. Nothing calls the service yet — the handlers still own their copies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * refactor(b3-2): thin auth handlers — decode, call AuthService, encode *db.DB leaves every handler signature in auth_handler.go and totp_handler.go; MountAuthRoutes takes the interface and the AuthMiddleware the caller builds, and router.go constructs the service after the hub. Each refusal is encoded by one writeAuthError switch on the service's error categories. The principal helper in middleware.go hands the handlers the caller as service.Principal, and userResponse moves next to the profile handler, so neither auth file names db any more: their two DBImportAllow rows go in this commit (TestDBImportAllowIsLive proves the rows could not outlive the import) and the boundary fixture points at middleware.go instead. The auth-slice limits leave api/constants.go with the code that reads them; profile_handler.go reads the shared pw_confirm budget from the service. Test files change only where they mount the routes (four helper lines + two direct mounts); no assertion or row moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(b3-2): after-state boundary inventory — api db importers 12 → 10 Regenerated table (49 files; move 28 → 26), the auth slice's after-state dependency rows, and the honest reading of the plan's "neither db nor service" target: met for db, not for service — the handlers import service for the interface's types and Err* categories. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(b3-2): evidence block — pre-squash SHAs, graph deltas, gates, coverage Characterization green at each SHA in a detached worktree with the frozen files byte-identical to 71d867cb; nine-method interface vs ten db methods; api db importers 12 → 10; slice coverage 392/433 = 90.5% → 392/427 = 91.8%; the five behaviour notes (decode-before-gate corner cases, shared AuthMiddleware, folded confirmation block, moved limits, moved converter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(hp-3): scorecard draft and the D4 vertical-slice pattern in server.md Five questions answered with commands and outputs at fe1d11b8/3f0d24ec; owner sign-off line left blank. server.md gains D4 — the eight-step interface/service/handler rule for B3-8 with the awkward step (gate-before-decode) named — and its D3 deviation note drops the auth routes. Plans README indexes the scorecard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(b3-2): record PR #1450 in the evidence block and the HP-3 fetch line Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
503 lines
19 KiB
Go
503 lines
19 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
)
|
|
|
|
// contextKey is an unexported type for context keys in this package.
|
|
type contextKey int
|
|
|
|
const (
|
|
// UserKey is the context key for the authenticated *db.User.
|
|
UserKey contextKey = iota
|
|
// SessionKey is the context key for the authenticated *db.Session.
|
|
SessionKey
|
|
// RoleKey is the context key for the *db.Role of the authenticated user.
|
|
RoleKey
|
|
)
|
|
|
|
// sessionTouchInterval is the minimum time between last_used writes for the
|
|
// same session. last_used feeds the sessions list in account settings, where
|
|
// minute granularity is plenty — writing it on every request just serialized
|
|
// API traffic behind the single SQLite writer.
|
|
const sessionTouchInterval = 60 * time.Second
|
|
|
|
// touchThrottleMaxEntries bounds the throttle map before stale entries are
|
|
// pruned. Entries older than sessionTouchInterval are prunable — they no
|
|
// longer suppress anything.
|
|
const touchThrottleMaxEntries = 4096
|
|
|
|
// touchThrottle remembers when each session hash was last touched so
|
|
// TouchSession runs at most once per sessionTouchInterval per session.
|
|
type touchThrottle struct {
|
|
mu sync.Mutex
|
|
seen map[string]time.Time
|
|
}
|
|
|
|
// shouldTouch reports whether the session's last_used write is due, and if so
|
|
// records now as the latest touch. Stale entries are pruned opportunistically
|
|
// once the map grows past touchThrottleMaxEntries.
|
|
func (t *touchThrottle) shouldTouch(hash string, now time.Time) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if last, ok := t.seen[hash]; ok && now.Sub(last) < sessionTouchInterval {
|
|
return false
|
|
}
|
|
if len(t.seen) >= touchThrottleMaxEntries {
|
|
cutoff := now.Add(-sessionTouchInterval)
|
|
for h, ts := range t.seen {
|
|
if ts.Before(cutoff) {
|
|
delete(t.seen, h)
|
|
}
|
|
}
|
|
}
|
|
t.seen[hash] = now
|
|
return true
|
|
}
|
|
|
|
// principal returns the caller AuthMiddleware resolved for r as the shape
|
|
// the service layer takes. ok is false when the request carries no
|
|
// authenticated user; Session is nil for an API-token principal.
|
|
func principal(r *http.Request) (service.Principal, bool) {
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
if !ok || user == nil {
|
|
return service.Principal{}, false
|
|
}
|
|
sess, _ := r.Context().Value(SessionKey).(*db.Session)
|
|
return service.Principal{User: user, Session: sess}, true
|
|
}
|
|
|
|
// AuthMiddleware reads the "Authorization: Bearer <token>" header, validates
|
|
// the session, and injects the user and session into the request context.
|
|
// Returns 401 if the token is missing, invalid, or the session is expired.
|
|
func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
|
touches := &touchThrottle{seen: make(map[string]time.Time)}
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, ok := auth.ExtractBearerToken(r)
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "missing or invalid authorization header",
|
|
})
|
|
return
|
|
}
|
|
|
|
hash := auth.HashToken(token)
|
|
// Resolve the bearer token to a principal. A login session is matched
|
|
// first (existing behavior unchanged); an API token is the fallback.
|
|
user, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash)
|
|
switch {
|
|
case errors.Is(err, auth.ErrTokenExpired):
|
|
// Clean up the expired login session in the background. The request
|
|
// ctx is cancelled once the 401 is written, so detach cancellation.
|
|
cleanupCtx := context.WithoutCancel(r.Context())
|
|
go func(h string) {
|
|
if err := database.DeleteSession(cleanupCtx, h); err != nil {
|
|
slog.WarnContext(cleanupCtx, "expired session cleanup failed", "error", err)
|
|
}
|
|
}(hash)
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "session has expired",
|
|
})
|
|
return
|
|
case errors.Is(err, auth.ErrUserNotFound):
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "user not found",
|
|
})
|
|
return
|
|
case errors.Is(err, auth.ErrRoleNotFound):
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "role not found",
|
|
})
|
|
return
|
|
case errors.Is(err, auth.ErrTokenNotFound):
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "invalid or expired session",
|
|
})
|
|
return
|
|
case err != nil:
|
|
// A wrapped DB error, not one of the sentinels above. A DB outage
|
|
// is not a bad token: answering 401 here would make the client
|
|
// treat a live, valid session as expired — it clears auth,
|
|
// disconnects the WS, and deletes the stored credential. Log it
|
|
// and report the failure as a server-side fault instead.
|
|
slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err)
|
|
writeJSON(w, http.StatusServiceUnavailable, errorResponse{
|
|
Error: "SERVICE_UNAVAILABLE",
|
|
Message: "authentication service temporarily unavailable",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Reject effectively-banned users before any further processing.
|
|
if auth.IsEffectivelyBanned(user) {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "your account has been suspended",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Touch last-used — non-fatal. A login session is touched inline but
|
|
// throttled to once per sessionTouchInterval per session, so hot API
|
|
// traffic doesn't queue a write per request; an API-token principal
|
|
// (sess == nil) is touched off the hot path so it never adds latency
|
|
// to bot/CI traffic.
|
|
if sess != nil {
|
|
if touches.shouldTouch(hash, time.Now()) {
|
|
if err := database.TouchSession(r.Context(), hash); err != nil {
|
|
slog.Warn("failed to touch session", "error", err, "user_id", user.ID)
|
|
}
|
|
}
|
|
} else {
|
|
touchCtx := context.WithoutCancel(r.Context())
|
|
go func(h string) {
|
|
if err := database.TouchAPIToken(touchCtx, h); err != nil {
|
|
slog.WarnContext(touchCtx, "failed to touch api token", "error", err)
|
|
}
|
|
}(hash)
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), UserKey, user)
|
|
ctx = context.WithValue(ctx, SessionKey, sess) // nil for API-token principals; consumers guard nil
|
|
ctx = context.WithValue(ctx, RoleKey, role)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequirePermission returns middleware gating a route on SERVER-WIDE role
|
|
// permissions. Returns 403 if the user lacks them.
|
|
//
|
|
// Scope contract — this is the whole reason the middleware and the service
|
|
// layer look like two permission systems:
|
|
// - It consults the role bitfield only. Channel overrides are NOT applied,
|
|
// because a route reaching this middleware has no channel id to resolve
|
|
// them against, and a per-channel allow must never open a server-wide gate.
|
|
// - Anything channel-scoped belongs in the service layer behind
|
|
// permissions.Checker (via svc.Permissions), which resolves overrides.
|
|
// - ADMINISTRATOR bypasses; multi-bit masks require ALL bits.
|
|
//
|
|
// The rule itself lives in permissions.HasServerPerm so no call site can
|
|
// re-derive it.
|
|
func RequirePermission(perm int64) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
role, ok := r.Context().Value(RoleKey).(*db.Role)
|
|
if !ok || role == nil {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "insufficient permissions",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !permissions.HasServerPerm(role.Permissions, perm) {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "insufficient permissions",
|
|
})
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// RateLimitMiddleware returns middleware that limits requests per IP using the
|
|
// provided RateLimiter. The client IP is resolved via clientIPWithProxies using
|
|
// the supplied trustedProxies CIDRs — pass nil to always use RemoteAddr.
|
|
// Returns 429 with Retry-After when the limit is exceeded.
|
|
//
|
|
// prefix names the endpoint's bucket and must be non-empty in production
|
|
// mounts: the limiter records one timestamp per call regardless of the limit
|
|
// passed, so endpoints sharing a bare-IP key would cap each other at the
|
|
// MINIMUM limit of any of them (ordinary profile edits 429ing the password
|
|
// endpoint, NAT'd logins blocking register).
|
|
func RateLimitMiddleware(limiter *auth.RateLimiter, prefix string, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler {
|
|
return rateLimitMiddlewareWithPrefix(limiter, prefix, limit, window, trustedProxies...)
|
|
}
|
|
|
|
func rateLimitMiddlewareWithPrefix(limiter *auth.RateLimiter, prefix string, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler {
|
|
var proxies []string
|
|
if len(trustedProxies) > 0 {
|
|
proxies = trustedProxies[0]
|
|
}
|
|
proxyNets := parseCIDRList(proxies) // W3-3a: parse once at construction
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ip := clientIPWithProxies(r, proxyNets)
|
|
key := prefix + ip
|
|
|
|
if !limiter.Allow(key, limit, window) {
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(window.Seconds())))
|
|
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
|
Error: "RATE_LIMITED",
|
|
Message: "too many requests, please slow down",
|
|
})
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
// clientIP returns the connecting IP from RemoteAddr, ignoring any proxy
|
|
// headers. It is safe to use for audit logging and lockout keys where proxy
|
|
// header trust has not been established. For rate-limiting with proxy support
|
|
// use clientIPWithProxies.
|
|
func clientIP(r *http.Request) string {
|
|
return clientIPWithProxies(r, nil)
|
|
}
|
|
|
|
// clientIPWithProxies returns the real client IP for rate-limiting purposes.
|
|
//
|
|
// Security model:
|
|
// - Always parse the actual connecting address from r.RemoteAddr.
|
|
// - Only honour X-Real-IP or X-Forwarded-For if the connecting address matches
|
|
// one of the trustedNets. This prevents clients from forging their IP to
|
|
// bypass rate limits.
|
|
// - If trustedNets is empty (the default), RemoteAddr is always used.
|
|
//
|
|
// trustedNets is the pre-parsed trusted-proxy list — parse the configured CIDR
|
|
// strings ONCE at middleware/handler construction with parseCIDRList (W3-3a);
|
|
// never parse on the request path.
|
|
func clientIPWithProxies(r *http.Request, trustedNets []*net.IPNet) string {
|
|
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
// RemoteAddr without port (e.g. Unix socket or test stub) — use as-is.
|
|
remoteHost = r.RemoteAddr
|
|
}
|
|
|
|
if len(trustedNets) == 0 {
|
|
return remoteHost
|
|
}
|
|
|
|
if !ipInNets(remoteHost, trustedNets) {
|
|
return remoteHost
|
|
}
|
|
|
|
// Prefer X-Forwarded-For when coming from a trusted proxy. Unlike
|
|
// X-Real-IP — which the project's own documented nginx and Caddy
|
|
// reverse-proxy recipes forward verbatim from whatever the client sent,
|
|
// rather than overwriting — every proxy fronting this server sets
|
|
// X-Forwarded-For with the true peer appended, so the anti-spoof walk
|
|
// below is authoritative and a client-injected X-Real-IP can never
|
|
// override it (OC-0240).
|
|
//
|
|
// Walk from the RIGHT and skip entries that are themselves trusted
|
|
// proxies. The first non-trusted, valid address is the real client.
|
|
// Taking the leftmost entry (BUG-112) would trust a client-supplied
|
|
// value: a client can prepend a spoofed IP
|
|
// (`X-Forwarded-For: <spoofed>, <real>`) that the proxy then appends to,
|
|
// letting it forge per-IP rate-limit and lockout keys.
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
leftmostValid := ""
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
candidate := strings.TrimSpace(parts[i])
|
|
if candidate == "" || net.ParseIP(candidate) == nil {
|
|
continue
|
|
}
|
|
leftmostValid = candidate
|
|
if ipInNets(candidate, trustedNets) {
|
|
continue // our own proxy hop, keep walking left
|
|
}
|
|
return candidate
|
|
}
|
|
// Every entry fell inside trustedCIDRs — a config that covers client
|
|
// networks too (e.g. trusted_proxies: 10.0.0.0/8 with LAN clients).
|
|
// Falling back to RemoteAddr here would collapse ALL clients behind
|
|
// the proxy into one rate-limit/lockout bucket, so one user's failed
|
|
// logins would lock out everyone. The leftmost valid entry is the
|
|
// furthest-upstream hop — the best distinct per-client key available
|
|
// under such a config. trusted_proxies must list only proxy hops;
|
|
// startup validation warns about entries that cannot be proxies.
|
|
if leftmostValid != "" {
|
|
return leftmostValid
|
|
}
|
|
}
|
|
|
|
// Fall back to X-Real-IP only when X-Forwarded-For was absent or wholly
|
|
// unusable. BUG-112 / OC-0240: still validate the extracted IP to prevent
|
|
// spoofed rate-limit keys — this header is untrustworthy on its own since
|
|
// the documented deployment recipes never overwrite it, so it is only
|
|
// ever used as a last resort, never ahead of X-Forwarded-For.
|
|
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
|
|
if net.ParseIP(xri) != nil {
|
|
return xri
|
|
}
|
|
}
|
|
|
|
return remoteHost
|
|
}
|
|
|
|
// parseCIDRList parses CIDR strings into networks, skipping invalid entries
|
|
// with a warning — a misconfigured entry must not take the server down. It is
|
|
// called once per middleware/handler at construction (startup), never on the
|
|
// request path (W3-3a).
|
|
func parseCIDRList(cidrs []string) []*net.IPNet {
|
|
nets := make([]*net.IPNet, 0, len(cidrs))
|
|
for _, c := range cidrs {
|
|
_, n, err := net.ParseCIDR(c)
|
|
if err != nil {
|
|
slog.Warn("ignoring invalid CIDR entry (use address/prefix notation, e.g. 10.0.0.1/32)",
|
|
"cidr", c, "error", err)
|
|
continue
|
|
}
|
|
nets = append(nets, n)
|
|
}
|
|
return nets
|
|
}
|
|
|
|
// ipInNets reports whether ipStr (a plain IP, no port) falls inside any of
|
|
// the parsed networks.
|
|
func ipInNets(ipStr string, nets []*net.IPNet) bool {
|
|
ip := net.ParseIP(ipStr)
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
for _, n := range nets {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AdminIPRestrict returns middleware that blocks requests from IPs not in the
|
|
// allowed CIDR list. Returns 403 Forbidden for disallowed IPs. If the CIDR
|
|
// list is empty, all requests are allowed (no restriction).
|
|
//
|
|
// trustedProxyCIDRs specifies which connecting IPs are trusted reverse proxies.
|
|
// When the connecting IP matches a trusted proxy, the real client IP is read
|
|
// from X-Real-IP or X-Forwarded-For headers (BUG-116).
|
|
//
|
|
// Both lists are parsed once at construction (W3-3a); invalid entries are
|
|
// skipped with a warning. A non-empty allowedCIDRs list whose entries are all
|
|
// invalid yields zero networks — nothing matches, so access is denied (fail
|
|
// closed), same as before the hoist.
|
|
func AdminIPRestrict(allowedCIDRs, trustedProxyCIDRs []string) func(http.Handler) http.Handler {
|
|
allowedNets := parseCIDRList(allowedCIDRs)
|
|
proxyNets := parseCIDRList(trustedProxyCIDRs)
|
|
restrict := len(allowedCIDRs) > 0
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !restrict {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
ip := clientIPWithProxies(r, proxyNets)
|
|
if !ipInNets(ip, allowedNets) {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "access denied",
|
|
})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// SecurityHeadersWithTLS returns middleware that sets a standard suite of
|
|
// defensive HTTP response headers. When tlsMode is non-empty (TLS is enabled),
|
|
// the Strict-Transport-Security header is also set.
|
|
//
|
|
// Header choices:
|
|
// - X-Content-Type-Options: nosniff — prevent MIME-type sniffing
|
|
// - X-Frame-Options: DENY — block clickjacking via iframes
|
|
// - X-XSS-Protection: 0 — disable legacy XSS filter; rely on CSP
|
|
// - Referrer-Policy: strict-origin-when-cross-origin
|
|
// - Content-Security-Policy: default-src 'self'
|
|
// - Permissions-Policy: camera=(), microphone=(), geolocation=()
|
|
// - Cache-Control: no-store — prevent sensitive data caching
|
|
// - Strict-Transport-Security (when TLS enabled)
|
|
func SecurityHeadersWithTLS(tlsMode string) func(http.Handler) http.Handler {
|
|
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("X-XSS-Protection", "0")
|
|
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
h.Set("Content-Security-Policy", "default-src 'self'")
|
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
h.Set("Cache-Control", "no-store")
|
|
if tlsMode != "" {
|
|
h.Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d; includeSubDomains", hstsMaxAgeSeconds))
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// MaxBodySize wraps r.Body with http.MaxBytesReader so that reads beyond
|
|
// maxBytes return an error. This prevents clients from exhausting server memory
|
|
// by sending arbitrarily large request bodies.
|
|
//
|
|
// Usage in the router:
|
|
//
|
|
// r.Use(MaxBodySize(1 << 20)) // 1 MiB default for API endpoints
|
|
//
|
|
// Upload endpoints that need a higher limit should apply their own
|
|
// http.MaxBytesReader or a route-scoped middleware with a larger value.
|
|
func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// MaxBodySizeUnless is like MaxBodySize but skips the limit for paths that
|
|
// match any of the given prefixes. Exempted paths apply their own limit via
|
|
// route-scoped middleware.
|
|
func MaxBodySizeUnless(maxBytes int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
exempt := false
|
|
for _, prefix := range exemptPrefixes {
|
|
if strings.HasPrefix(r.URL.Path, prefix) {
|
|
exempt = true
|
|
break
|
|
}
|
|
}
|
|
if !exempt {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// errorResponse is the standard error JSON shape.
|
|
type errorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
}
|