fix: review round 4 — an account switch unloads as the old account, a share link's lockout costs five networks

Deep-review round 4, targeted at round 3's changes (f569b19c..e7f38396).

Accounts
- Switching, adding or signing out an account moved the tab's pin before
  the page unloaded, so the players' resume beacons fired on the way out
  under the NEW account: one account's playback position saved into the
  other. The pin is now handed to the next page (pinOnNextPage) and taken
  over at import; clearActiveAccount is gone.
- Switching to a suspended account was refused but left it in the
  switcher, refused on every click; a deactivated one was not refused at
  all, and made the default it signed every account in the browser out on
  the next request. Every account _session_user would refuse — gone,
  deactivated, suspended, revoked — is refused and dropped from the list.

Share page (/watch)
- An IPv6 client is limited by its /64 (client_ip becomes client_key, for
  every limiter): keyed on the full address, one subscriber could rotate
  through endless identities. The comments now state what the per-link
  ceiling costs: guessing from five networks can keep a link shut for its
  window, and guesses already running when it fills are still checked.
- The link is looked up before the limiter records the attempt, so made-up
  tokens no longer grow it by an entry each.

Forms
- Failing the same way twice is announced again: the sign-in, passphrase
  and setup errors are keyed on the attempt (a clear and a set in one
  handler left the alert node untouched).
- The install wizard's own validation showed the generic error — it threw
  plain objects its catch did not read. It sets the step's message.

Tests: a re-pin leaving the previous account's notices behind; the pin
hand-over; switching to an ended account; the /64 viewer; made-up tokens;
the repeated alerts. Each mutation-checked RED for its own reason.
This commit is contained in:
2026-09-15 02:53:22 +02:00
parent e7f38396a2
commit 47d968eeea
18 changed files with 316 additions and 76 deletions
+25
View File
@@ -102,6 +102,31 @@ def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_defaul
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_from_the_list(
client, make_user, db, monkeypatch, ended
):
# The account switcher lists this browser's wallet. A revoked account was dropped from it on a refused
# switch, but a suspended one stayed, refused on every click; a deactivated one was not refused at all —
# made the default, the next request found it inactive and signed every account here out.
monkeypatch.setattr("app.routes.me.is_allowed", lambda _db, _email: True) # both hold an invite
alice = make_user(email="alice5@example.com", password=_PW)
make_user(email="bob5@example.com", password=_PW)
assert _login(client, "alice5@example.com", _PW).status_code == 200
assert _login(client, "bob5@example.com", _PW).status_code == 200
if ended == "revoked":
alice.session_epoch = (alice.session_epoch or 0) + 1
elif ended == "suspended":
alice.is_suspended = True
else:
alice.is_active = False
db.commit()
assert client.post("/api/me/switch", json={"user_id": alice.id}).status_code == 403
assert [a["email"] for a in client.get("/api/me/accounts").json()] == ["bob5@example.com"]
assert client.get("/api/feed").status_code == 200 # Bob is still signed in
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
+23 -2
View File
@@ -1,7 +1,8 @@
"""The password unlock of a shared /watch link (routes/public.py), DB-lane.
Guesses are slowed per viewer (the link AND the client), under a per-link ceiling on wrong passwords, so
someone who holds a password link without its password can't lock out the viewers who have it.
Guesses are slowed per viewer (the link AND the client — an IPv6 client by its /64), under a per-link
ceiling on wrong passwords, so a client guessing wrong shuts out only itself until it spreads its guesses
over as many networks as the ceiling allows.
"""
import pytest
@@ -46,6 +47,26 @@ def test_a_link_holder_without_the_password_cannot_lock_out_its_viewers(unlock_u
assert _from("198.51.100.7").post(unlock_url, json={"password": _PW}).status_code == 200
def test_an_ipv6_client_is_one_viewer_across_its_64(unlock_url):
# One subscriber gets a whole /64: keyed on the full address, it could rotate through endless viewers.
for _ in range(10):
wrong = _from("2001:db8:1:2::5").post(unlock_url, json={"password": "guess"})
assert wrong.status_code == 403
assert _from("2001:db8:1:2::6").post(unlock_url, json={"password": _PW}).status_code == 429
assert _from("2001:db8:1:3::5").post(unlock_url, json={"password": _PW}).status_code == 200
def test_a_token_naming_no_link_leaves_nothing_in_the_limiter(unlock_url):
# The limiter used to record the attempt before looking the link up: posting made-up tokens grew it by
# an entry each, kept for the whole window.
keys = len(public._unlock_limit._hits)
for n in range(5):
missing = _from("203.0.113.5").post(unlock_url.replace("/watch/", f"/watch/x{n}"), json={})
assert missing.status_code == 404
assert len(public._unlock_limit._hits) == keys
def test_wrong_passwords_from_many_clients_still_reach_the_links_ceiling(unlock_url, monkeypatch):
monkeypatch.setattr(public, "_unlock_failures", RateLimiter(max_events=3, window_seconds=300))
viewer = _from("198.51.100.7")