mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
479 lines
17 KiB
Go
479 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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-Real-IP when coming from a trusted proxy.
|
|
// BUG-112: Validate extracted IP to prevent spoofed rate-limit keys.
|
|
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
|
|
if net.ParseIP(xri) != nil {
|
|
return xri
|
|
}
|
|
}
|
|
|
|
// Fall back to X-Forwarded-For, walking from the RIGHT and skipping 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
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|