An open /api/events stream keeps sending daemon events to an account after it is removed #335

Closed
opened 2026-10-23 05:26:51 -07:00 by rays · 1 comment
Owner

Found by the security-audit skill (run-1, 2026-10-20). Source-read only: nothing was run, so this is a lead that still needs its local check, not a confirmed finding. Two independent verifiers did not disprove it.

GET /api/events authenticates once, through the auth middleware, when the stream opens; events() then moves the User value into a stream::unfold state and loops on the broadcast receiver for the life of the connection. Removing the account (web remove_user, which calls main.rs remove_user and Db::delete_user, or the CLI) deletes the user's subscriptions, sessions, API tokens and user row, but nothing closes, signals or re-checks streams already open, and the default SSE keep-alive comments stop the connection idling out. Caddy proxies /api/events with flush_interval -1, so the stream passes through ipx-web. A removed household member whose page stays open therefore keeps receiving every broadcast daemon event (feed ids, titles, scan results, download progress and errors, after Event::redacted strips queries and passwords from addresses) until the connection drops. The per-user feed_row follow-up is not leaked: feed_rows re-reads subscriptions_for(user.id), which are gone after removal, so rows.first() is None. The residual exposure is the same household-wide event stream any signed-in user sees, continued past the moment removal was meant to end it.

Claimed root cause. events() in crates/ipx/src/web.rs subscribes to the broadcast channel and loops over rx.recv() with the User captured at connect; it never re-validates the session, token or user row per event, and Db::delete_user has no mechanism (watch channel, cancellation token) to end open streams for the deleted id.

Local check. {'local': "In a sandbox, start a scratch daemon on SQLite with fixture feeds; create dummy users alice (admin) and bob, sign bob in and open GET /api/events with his session cookie (curl -N); DELETE /api/users/ as alice; trigger a fetch of a fixture feed and observe whether bob's open stream still receives the feed_done event, and that no feed_row event follows. Fix: give delete_user (and password change/session revocation) a per-user revocation signal, e.g. a tokio watch or broadcast of removed user ids that events() selects on alongside rx.recv(), or re-check user_by_id before sending each event, and end the stream when the account is gone; a regression test asserts the stream closes after removal.", 'deployment': "Owner-observed, non-destructive: with the page open as a throwaway test account through ipodderx.sdf1.net, remove that account from Admin and watch the browser's network panel for whether the /api/events response stays open and keeps delivering events, and for how long before the tunnel closes it."}

Owner check (no probing). Owner-observed, non-destructive: with the page open as a throwaway test account through ipodderx.sdf1.net, remove that account from Admin and watch the browser's network panel for whether the /api/events response stays open and keeps delivering events, and for how long before the tunnel closes it.

Full detail: /config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md (fingerprint ipx:web.rs:events:sse-stream-outlives-account-removal).

Found by the security-audit skill (run-1, 2026-10-20). Source-read only: nothing was run, so this is a lead that still needs its local check, not a confirmed finding. Two independent verifiers did not disprove it. GET /api/events authenticates once, through the auth middleware, when the stream opens; events() then moves the User value into a stream::unfold state and loops on the broadcast receiver for the life of the connection. Removing the account (web remove_user, which calls main.rs remove_user and Db::delete_user, or the CLI) deletes the user's subscriptions, sessions, API tokens and user row, but nothing closes, signals or re-checks streams already open, and the default SSE keep-alive comments stop the connection idling out. Caddy proxies /api/events with flush_interval -1, so the stream passes through ipx-web. A removed household member whose page stays open therefore keeps receiving every broadcast daemon event (feed ids, titles, scan results, download progress and errors, after Event::redacted strips queries and passwords from addresses) until the connection drops. The per-user feed_row follow-up is not leaked: feed_rows re-reads subscriptions_for(user.id), which are gone after removal, so rows.first() is None. The residual exposure is the same household-wide event stream any signed-in user sees, continued past the moment removal was meant to end it. **Claimed root cause.** events() in crates/ipx/src/web.rs subscribes to the broadcast channel and loops over rx.recv() with the User captured at connect; it never re-validates the session, token or user row per event, and Db::delete_user has no mechanism (watch channel, cancellation token) to end open streams for the deleted id. **Local check.** {'local': "In a sandbox, start a scratch daemon on SQLite with fixture feeds; create dummy users alice (admin) and bob, sign bob in and open GET /api/events with his session cookie (curl -N); DELETE /api/users/<bob id> as alice; trigger a fetch of a fixture feed and observe whether bob's open stream still receives the feed_done event, and that no feed_row event follows. Fix: give delete_user (and password change/session revocation) a per-user revocation signal, e.g. a tokio watch or broadcast of removed user ids that events() selects on alongside rx.recv(), or re-check user_by_id before sending each event, and end the stream when the account is gone; a regression test asserts the stream closes after removal.", 'deployment': "Owner-observed, non-destructive: with the page open as a throwaway test account through ipodderx.sdf1.net, remove that account from Admin and watch the browser's network panel for whether the /api/events response stays open and keeps delivering events, and for how long before the tunnel closes it."} **Owner check (no probing).** Owner-observed, non-destructive: with the page open as a throwaway test account through ipodderx.sdf1.net, remove that account from Admin and watch the browser's network panel for whether the /api/events response stays open and keeps delivering events, and for how long before the tunnel closes it. Full detail: `/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md` (fingerprint `ipx:web.rs:events:sse-stream-outlives-account-removal`).
rays added the bug label 2026-10-23 05:26:51 -07:00
Author
Owner

Fixed in 91de111: each events stream looks its account and subscriptions up again at most every 30 s, as events arrive, and ends when the account is gone. Deployed.

Fixed in 91de111: each events stream looks its account and subscriptions up again at most every 30 s, as events arrive, and ends when the account is gone. Deployed.
rays closed this issue 2026-10-23 07:53:05 -07:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: rays/ipx#335