Use safer defaults for newly created service endpoints and guide operators toward suitable access credentials without disrupting existing deployments.
Apply the same policy at the save boundary, retain deliberate local-only setups and external client compatibility, and add regression coverage for editing, recovery, and trusted access paths.
The previous commit tightened login rate-limiting to key off the real
TCP socket peer, which fixed the X-Forwarded-For bypass but had the
known caveat of collapsing every client into a single global bucket
when the WebUI runs behind nginx / Caddy / a SaaS CDN.
Give operators an explicit, audited way to opt back into per-client
keying without re-introducing the spoof:
* `SNOWLUMA_WEBUI_TRUST_PROXY` env var. Modes:
- unset / empty → keep socket peer (safe default, unchanged)
- "loopback" → trust X-Real-IP / X-Forwarded-For when the
immediate socket peer is 127.0.0.1 / ::1
(nginx + WebUI on the same host)
- "1" / "true" / → trust headers from any peer (only safe behind
"all" a SaaS proxy that strips client-set headers)
- comma-separated → trust headers only when the socket peer
IP list matches one of these (proxy on another box)
* When trusting headers we prefer X-Real-IP (single value the proxy
overwrites unconditionally) and fall back to the first hop of XFF.
We deliberately do NOT walk the full XFF chain — without a fully
trusted proxy chain the leftmost value is still spoofable; a
single-tier proxy is the only setup this app realistically sees.
* Boot log prints "login rate-limit keyed by: …" so operators can
verify the mode took effect, plus an extra WARN for "all" reminding
them it's only safe with header-stripping in front.
Logic lives in webui/client-ip.ts as a pure pickClientIp() that takes
a socket-IP resolver, so the trust-proxy matrix can be exercised
without standing up a server — see webui-client-ip.test.ts for the
11 new unit tests covering parse + dispatch + loopback / list / all /
none modes.
Smoke-tested with SNOWLUMA_WEBUI_TRUST_PROXY=loopback:
- 5× failed login w/ X-Forwarded-For: 1.1.1.1 → 401
- 6th attempt same XFF → 429 (locked, per-XFF)
- 6th attempt X-Forwarded-For: 2.2.2.2 → 401 (fresh bucket)
- X-Real-IP overrides XFF when both present