Commit Graph
2 Commits
Author SHA1 Message Date
bvandeusen 381e9cedb7 feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
test-go / test (push) Failing after 50s
test-web / test (push) Failing after 50s
test-go / integration (push) Failing after 2m19s
Fixes the defect the operator spotted in #370 immediately after it shipped:
auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a
proxy on a public address — a separate host, or a CDN, i.e. anyone running
this publicly, since public means TLS means a proxy — recorded the PROXY for
every session. created_ip and last_ip were then always equal and the
"Address changed" signal could never fire. The feature looked like it worked
and reported nothing.

Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx).
XFF grows left-to-right as each proxy appends the peer it received from, so
for client -> CDN -> own-proxy -> app the app sees [client, CDN] with
RemoteAddr = own-proxy, and the client sits at XFF[len - hops]:

  0  RemoteAddr, XFF ignored — no proxy
  1  the address your own proxy observed
  2  through a CDN in front of your proxy

Default 1, per the operator: publicly reachable means a TLS terminator in
front.

The cost is real and stated rather than hidden. hops >= 1 DECLARES that a
proxy exists; set it with no proxy, or deeper than the actual chain, and the
index reaches attacker-supplied entries, letting a visitor choose which
address their own session shows — defeating exactly the detection #370 is
for. That's inherent to the model, which is why 0 is a first-class value and
the admin card says "count your proxies, don't guess high" instead of just
exposing a number. Both mis-set shapes are pinned by tests so they stay known
consequences rather than surprises.

Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an
optimisation: ClientIP runs in RequireUser for every authenticated request, so
a per-request query would put the database on the critical path of the whole
API. New() always returns a usable service so a boot-time DB hiccup degrades
to the default instead of breaking that path (rule #131), and Hops() is
nil-safe because test routers construct middleware without it.

RequireUser now takes a func() int rather than an int — the value is
operator-editable at runtime while the middleware is built once at boot, and
reading it per request is what makes a save take effect with no restart
(rule #25).

The admin card is verifiable, not just configurable: it reports the address
the CURRENT setting resolves THIS request to, the raw forwarded chain, and the
socket peer — so you set the number, save, and confirm the address matches the
machine you're on. It also counts the arriving chain and says how many proxies
that implies. GET/PUT both return that payload, PUT recomputed under the new
value, so the effect is visible without a reload.

Also fixes styling in the #370 card that CI could not catch: text-destructive
and bg-destructive don't exist in this Tailwind config — the palette is
colors.action.destructive — so the "Address changed" warning and the
sign-out-others button were rendering unstyled. Both now use
text-action-destructive / bg-action-destructive / text-action-fg.

Not done here: requestlog.go still logs raw RemoteAddr and will disagree with
the sessions UI about who connected. Left for its own change.
2026-08-05 10:07:43 -04:00
bvandeusen bf649f3beb feat(web): active sessions card in Settings — #370
test-web / test (push) Successful in 32s
Client half of #370. Lists every device signed in to your account, with a
per-row sign-out and a "sign out all other devices" action.

The card does one thing the API alone doesn't: it says "Address changed" when
created_ip and last_ip differ, rather than printing two addresses and leaving
you to compare them. That mismatch — same device string, different origin —
is the shape of a stolen token, and it's the reason IP capture was worth a
migration. Making the operator spot it by eye would have wasted the data.

Placed with Password and API Token rather than at the bottom of the page:
those three are the account-security group, and this is the one that tells
you the other two need attention.

Details worth naming:

- The current session gets a "This device" badge and NO sign-out button —
  offering one would log you out of the page you're standing on. The server
  already excludes it from logout-others; this makes that visible.
- Sign-out-all-others is a two-step confirm and states the count, so the
  button can't be a surprise.
- A 404 on revoke reloads instead of erroring. It means the session is
  already gone — revoked elsewhere, or expired — so the list was simply
  stale and showing the truth is the right response. The code is
  `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it.
- Empty and error states both handled (rule #24); the empty case is
  practically unreachable since listing requires an authenticated request,
  and is handled rather than assumed.
- User-agent parsing is deliberately coarse. A real UA parser is a
  dependency and a maintenance burden for a string whose only job is "do you
  recognise this?" — the addresses carry the actual signal.

Tests cover the parts that would be quiet if broken: the current-session
badge suppressing its own sign-out button, the address-changed warning
appearing and NOT appearing, the two-step confirm not firing on first click,
and the load-failure retry.

Android parity is a separate decision, not assumed.
2026-08-05 09:25:20 -04:00