fix(sessions): show external IP and a detected device on Active Sessions (BUNYIP-409) #404

Merged
longjacksonle merged 1 commit from fix/BUNYIP-409-session-ip-device into main 2026-07-29 00:23:11 +02:00

Symptoms

The Active Sessions list showed bunyip-api's Docker-internal peer IP and "Unknown device" for every session.

Trace (before changing code)

Active Sessions reads refresh_tokens, created in bunyip-api's login/register/2FA/approval handlers from extract_client_ip(req) + extract_device_info(req). But that request is bunyip-web -> bunyip-api server-to-server, so both the peer IP and the User-Agent are bunyip-web's, not the browser's.

  • IP: extract_client_ip is already the correct forwarded-header-aware extractor (same one access_log.rs / root_span.rs use, with anti-spoof tests). It honours a forwarded X-Forwarded-For only when the peer is inside TRUSTED_PROXY_CIDR - and both that (api) and WEB_TRUSTED_PROXY_CIDR (web) defaulted to empty. With no trusted proxy configured, the BFF forwards nothing and the API falls back to the Docker peer.
  • Device: the server-to-server call carried bunyip-web's HTTP-client UA (or none), never the browser's.

Fix

IP (config, no extractor change): default both TRUSTED_PROXY_CIDR (api) and WEB_TRUSTED_PROXY_CIDR (web) to the RFC1918 private ranges in compose.yml and compose.dev.yml. The API is only reachable over the internal Docker network + Traefik, so the only peers are trusted infra; .env.example already recommended exactly these ranges. The extractor and its anti-spoofing tests are unchanged - a forged header from an untrusted peer is still ignored.

Device (code): bunyip-web captures the inbound browser User-Agent into a task-local (mirroring the existing client-IP forwarding) and sets it on every outbound /v1 call, so extract_device_info records the real browser UA. New bunyip_domain::device::device_label maps a UA to "Chrome on macOS" / "Safari on iOS" etc. (browser + OS via substring detection, no new dependency), applied at read time in list_sessions so existing rows benefit with no backfill. An absent/unrecognisable UA -> None -> the web keeps its "Unknown device" fallback.

Acceptance criteria

  • Persisted session IP is the external client address, not the Docker peer (via the forwarded header now being trusted).
  • Forwarded header only honoured through a trusted proxy (existing resolve_client_ip / resolve_forwarded_ip tests; extractor unchanged).
  • Sessions show a device name derived from the UA, with "Unknown device" retained as fallback.
  • Observed run through the production ingress - for post-deploy confirmation with the new compose defaults.

Verification

  • just check-container green: fmt + clippy -D warnings + all test binaries (121 web tests). New tests: the device-label parser (Edge-vs-Chrome and iOS-vs-macOS token ordering; None for unknown/absent UAs) and that the BFF middleware exposes the browser UA to the outbound-call path.

Note: I could not do the observed production-ingress run locally - the dev stack is currently blocked by a pre-existing Postgres major-version mismatch on the persisted dev volume (unrelated to this change; I left the volume untouched).

🤖 Generated with Claude Code

## Symptoms The Active Sessions list showed bunyip-api's Docker-internal peer IP and "Unknown device" for every session. ## Trace (before changing code) Active Sessions reads **refresh_tokens**, created in bunyip-api's login/register/2FA/approval handlers from `extract_client_ip(req)` + `extract_device_info(req)`. But that request is **bunyip-web -> bunyip-api server-to-server**, so both the peer IP and the User-Agent are bunyip-web's, not the browser's. - **IP**: `extract_client_ip` is already the correct forwarded-header-aware extractor (same one `access_log.rs` / `root_span.rs` use, with anti-spoof tests). It honours a forwarded `X-Forwarded-For` only when the peer is inside `TRUSTED_PROXY_CIDR` - and both that (api) and `WEB_TRUSTED_PROXY_CIDR` (web) defaulted to **empty**. With no trusted proxy configured, the BFF forwards nothing and the API falls back to the Docker peer. - **Device**: the server-to-server call carried bunyip-web's HTTP-client UA (or none), never the browser's. ## Fix **IP (config, no extractor change):** default both `TRUSTED_PROXY_CIDR` (api) and `WEB_TRUSTED_PROXY_CIDR` (web) to the RFC1918 private ranges in `compose.yml` and `compose.dev.yml`. The API is only reachable over the internal Docker network + Traefik, so the only peers are trusted infra; `.env.example` already recommended exactly these ranges. The extractor and its anti-spoofing tests are unchanged - a forged header from an untrusted peer is still ignored. **Device (code):** bunyip-web captures the inbound browser `User-Agent` into a task-local (mirroring the existing client-IP forwarding) and sets it on every outbound `/v1` call, so `extract_device_info` records the real browser UA. New `bunyip_domain::device::device_label` maps a UA to "Chrome on macOS" / "Safari on iOS" etc. (browser + OS via substring detection, no new dependency), applied at read time in `list_sessions` so existing rows benefit with no backfill. An absent/unrecognisable UA -> `None` -> the web keeps its "Unknown device" fallback. ## Acceptance criteria - [x] Persisted session IP is the external client address, not the Docker peer (via the forwarded header now being trusted). - [x] Forwarded header only honoured through a trusted proxy (existing `resolve_client_ip` / `resolve_forwarded_ip` tests; extractor unchanged). - [x] Sessions show a device name derived from the UA, with "Unknown device" retained as fallback. - [ ] Observed run through the production ingress - for post-deploy confirmation with the new compose defaults. ## Verification - `just check-container` green: fmt + clippy `-D warnings` + all test binaries (121 web tests). New tests: the device-label parser (Edge-vs-Chrome and iOS-vs-macOS token ordering; `None` for unknown/absent UAs) and that the BFF middleware exposes the browser UA to the outbound-call path. Note: I could not do the observed production-ingress run locally - the dev stack is currently blocked by a pre-existing Postgres major-version mismatch on the persisted dev volume (unrelated to this change; I left the volume untouched). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(sessions): show external IP and a detected device on Active Sessions (BUNYIP-409)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 50s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m20s
Create release / Create release from merged PR (pull_request) Has been skipped
25d62b1d6e
Two defects on the Active Sessions list (which reads refresh_tokens): the row showed bunyip-api's Docker-internal peer IP, and the device was always "Unknown device".

Traced both before changing code. The session rows are created in bunyip-api's login/register/2FA/approval handlers from `extract_client_ip(req)` + `extract_device_info(req)`, but the request is bunyip-web -> bunyip-api server-to-server, so:

1. IP: `extract_client_ip` is already the correct forwarded-header-aware extractor (the same one access_log/root_span use, with anti-spoof tests) - but it only honours a forwarded X-Forwarded-For when the peer is inside TRUSTED_PROXY_CIDR, and both that and bunyip-web's WEB_TRUSTED_PROXY_CIDR defaulted to empty. With no trusted proxy configured the BFF forwards nothing and the API falls back to the Docker peer. Fixed by defaulting both to the RFC1918 private ranges in compose.yml and compose.dev.yml (the API is only reachable over the internal Docker network + Traefik, so the only peers are trusted infra; the .env.example already recommended exactly these ranges). No extractor change, so the anti-spoofing posture and its tests are unchanged - a forged header from an untrusted peer is still ignored.

2. Device: the server-to-server call carried the BFF's HTTP-client User-Agent (or none), never the browser's, so `device_info` was meaningless. bunyip-web now captures the inbound browser User-Agent into a task-local (mirroring the existing client-IP forwarding) and sets it on every outbound /v1 call, so `extract_device_info` records the real browser UA. A new `bunyip_domain::device::device_label` maps that UA to "Chrome on macOS" / "Firefox on Windows" / "Safari on iOS" etc. (browser + OS via substring detection, no new dependency), applied at read time in `list_sessions` so existing rows benefit with no backfill. An absent or unrecognisable UA (e.g. a non-browser client) still yields None -> the web keeps its "Unknown device" fallback.

Verified: `just check-container` green (fmt + clippy -D warnings + all test binaries, 121 web tests). New tests cover the device-label parser (incl. Edge-vs-Chrome and iOS-vs-macOS token ordering, and None for unknown/absent UAs) and that the BFF middleware exposes the browser UA to the outbound-call path. The anti-spoof resolution is covered by the existing resolve_client_ip / resolve_forwarded_ip tests (AC: forwarded header only honoured through a trusted proxy). The observed production-ingress run is for post-deploy confirmation with the new compose defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5dcYueNHByRnWJDYoDX1W
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-29 00:21:08 +02:00
longjacksonle deleted branch fix/BUNYIP-409-session-ip-device 2026-07-29 00:23:11 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!404
No description provided.