Files
MindSift/internal/api/clientip.go
T
sandrodachandClaude Opus 5.5 11c14fa9ff
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
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>
2026-10-29 14:26:02 +01:00

145 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}
}