feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

Failed logins are counted per account+address (3), per account (20) and
per address (30) within 15 minutes; a full bucket answers 429 with
Retry-After for 15 minutes. Locking the account+address bucket first
means an attacker cannot lock the owner out from elsewhere. Unknown
names count like accounts. Success clears the account buckets, a
password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false
switches it off.

chi's RealIP believed X-Forwarded-For from anyone. The client address
now comes from the headers only when the connection is from a trusted
proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all
(old behaviour), none, or CIDRs. A proxy that sends no client address
is logged once and per-address limits are skipped for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:26:02 +01:00
1 parent 6b9eb62caf
commit 11c14fa9ff
12 files changed
+503 -30

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
package api
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"sync"
)
// Client IP behind a reverse proxy. The proxy connects to MindSift and passes the visitor's address in
// X-Forwarded-For / X-Real-IP. Those headers are only believed when the connection comes from a trusted
// proxy (TRUSTED_PROXIES), otherwise anyone reaching the port directly could claim any address and dodge
// the login protection.
// private (default) loopback and private networks (Docker, LAN, CGNAT/Tailscale)
// all always believe the headers (old behaviour, switches the check off)
// none never believe them
// CIDR list e.g. "private,203.0.113.0/24"; Cloudflare and other external proxies go here
type clientIPKey struct{}
type clientIPInfo struct {
ip string
// viaProxyWithoutHeader: the connection came from a trusted proxy that did not say who the
// visitor is, so all visitors look the same. Per-IP limits must not apply then.
viaProxyWithoutHeader bool
}
type proxyTrust struct {
all bool
networks []*net.IPNet
}
var privateProxyNets = mustCIDRs("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
func parseProxyTrust(spec string) proxyTrust {
spec = strings.ToLower(strings.TrimSpace(spec))
if spec == "" {
spec = "private"
}
var t proxyTrust
for _, part := range strings.Split(spec, ",") {
switch part = strings.TrimSpace(part); part {
case "", "none":
case "all", "*":
t.all = true
case "private":
t.networks = append(t.networks, privateProxyNets...)
default:
if !strings.Contains(part, "/") {
if strings.Contains(part, ":") {
part += "/128"
} else {
part += "/32"
}
}
if _, n, err := net.ParseCIDR(part); err == nil {
t.networks = append(t.networks, n)
} else {
slog.Warn("TRUSTED_PROXIES: Eintrag ignoriert", "entry", part)
}
}
}
return t
}
func (t proxyTrust) trusts(ip net.IP) bool {
if t.all {
return true
}
for _, n := range t.networks {
if n.Contains(ip) {
return true
}
}
return false
}
func peerIP(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// resolveClientIP returns the visitor's address for a request coming from peer.
func (t proxyTrust) resolveClientIP(peer string, h http.Header) clientIPInfo {
peerAddr := net.ParseIP(peer)
if peerAddr == nil || !t.trusts(peerAddr) {
return clientIPInfo{ip: peer}
}
// X-Forwarded-For: client, proxy1, proxy2 – the visitor is the right-most address that is not one of
// our proxies (everything left of it could be made up by the visitor)
if xff := h.Get("X-Forwarded-For"); xff != "" {
var chain []net.IP
for _, part := range strings.Split(xff, ",") {
if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil {
chain = append(chain, ip)
}
}
if len(chain) > 0 {
if t.all {
return clientIPInfo{ip: chain[0].String()} // former behaviour: first address counts
}
for i := len(chain) - 1; i >= 0; i-- {
if !t.trusts(chain[i]) {
return clientIPInfo{ip: chain[i].String()}
}
}
return clientIPInfo{ip: chain[0].String()} // only proxies in the chain: the visitor is in our network
}
}
for _, name := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP"} {
if ip := net.ParseIP(strings.TrimSpace(h.Get(name))); ip != nil {
return clientIPInfo{ip: ip.String()}
}
}
return clientIPInfo{ip: peer, viaProxyWithoutHeader: !peerAddr.IsLoopback()}
}
var warnProxyHeaderOnce sync.Once
// clientIPMiddleware replaces chi's RealIP: r.RemoteAddr becomes the visitor's address.
func (s *Server) clientIPMiddleware(next http.Handler) http.Handler {
trust := parseProxyTrust(s.cfg.TrustedProxies)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := trust.resolveClientIP(peerIP(r.RemoteAddr), r.Header)
if info.viaProxyWithoutHeader {
warnProxyHeaderOnce.Do(func() {
slog.Warn("Reverse-Proxy übergibt keine Client-IP (X-Forwarded-For/X-Real-IP fehlt); Login-Sperren pro IP sind für diese Anfragen aus", "proxy", info.ip)
})
}
r.RemoteAddr = info.ip
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey{}, info)))
})
}
func clientIPFrom(r *http.Request) clientIPInfo {
if info, ok := r.Context().Value(clientIPKey{}).(clientIPInfo); ok {
return info
}
return clientIPInfo{ip: peerIP(r.RemoteAddr)}
}