An open /api/events stream keeps sending daemon events to an account after it is removed #335
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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(fingerprintipx:web.rs:events:sse-stream-outlives-account-removal).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.