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>
145 lines
4.4 KiB
Go
145 lines
4.4 KiB
Go
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)}
|
||
}
|