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
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:
1 parent
6b9eb62caf
commit
11c14fa9ff
12 files changed
+503
-30
No files matched your search
@@ -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)}
|
||||
}
|
||||
Reference in new issue
Block a user