security: parse X-Forwarded-For from trusted proxies for rate-limit key
Resolves plan item A.1 (option б): login/setup rate-limiting used RemoteAddr only, which behind the default reverse proxy is the proxy's own address, making the limiter effectively global and enabling a lockout-DoS. Now, when the request's direct peer matches the new TRUSTED_PROXY_CIDR list (comma-separated CIDRs, env, empty by default), the last X-Forwarded-For entry is used instead, giving a real per-client limit. Unset behaviour is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,12 +26,13 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||
|
||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
}, cfg.setupTokenPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
@@ -56,6 +58,7 @@ type config struct {
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
trustedProxies []*net.IPNet
|
||||
|
||||
opendkimDir string
|
||||
dkimSelectorDef string
|
||||
@@ -83,6 +86,11 @@ func loadConfig() config {
|
||||
// Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
// Reverse-proxy addresses allowed to supply X-Forwarded-For for
|
||||
// rate-limiting (plan.md item A.1). Empty by default: an untrusted peer's
|
||||
// XFF header is trivially forgeable, so it's ignored unless the panel is
|
||||
// told which proxy to trust.
|
||||
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
|
||||
|
||||
// Per-domain DKIM state (spec 6). The directory layout matches what
|
||||
// entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
@@ -131,6 +139,37 @@ func envInt(key string, def int) int {
|
||||
return def
|
||||
}
|
||||
|
||||
// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are
|
||||
// accepted and treated as /32 or /128). Invalid entries are logged and
|
||||
// skipped rather than failing startup, matching envInt's tolerance of
|
||||
// misconfiguration.
|
||||
func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var nets []*net.IPNet
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
cidr := strings.TrimSpace(part)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
cidr += "/128"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err)
|
||||
continue
|
||||
}
|
||||
nets = append(nets, n)
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all roles;
|
||||
// a role error cancels the others and is returned so the process exits non-zero
|
||||
|
||||
Reference in New Issue
Block a user