Repository navigation
1.9.79: a new password ends the user's other sessions - #331
Conversation
#325 revoked the remember-me keys on a new password and left every browser already signed in signed in, so a thief who had used a stolen cookie kept the session it made after the owner changed or reset the password. Measured live on #326's head: after a change on Personal settings, a reset, PUT /users/{id} and Account › Users, the other browsers stayed signed in. 202_users gains user_session_generation (INT UNSIGNED NOT NULL DEFAULT 0), in the table definition and the 1.9.78 -> 1.9.79 rung. A counter, not a time: no same-second race, no clock to skew between PHP and the database. - The sign-in (LOGIN_SELECT, and the cookie's SELECT *) stores the user's generation in the session (begin_user_session()). - SessionGeneration::bump() counts it up, in the writer's transaction, wherever the user's remember-me keys are revoked: a new password (PasswordChange::store(), Account › Users, PUT /users/{id}, MysqlUserRepository::update()), a switch-off (a session idle through the switch-off would otherwise go on once the user is switched on again: measured), and the removal's soft delete. A failed count rolls the change back. The rehash at sign-in does not count. - connect.php's User read (UserStanding::READ) reads the generation in the same statement; AUTH ends a session holding another at that request, with the sign-out's teardown but without revoking keys again (the change revoked them; keys made since are a later sign-in's). A failed read is the error page. Before the upgrade has run the column is missing: the read retries without it (that request only) and compares nothing. - A session from before 1.9.79 holds no generation and is read as 0, every user's value until a change, so the upgrade signs nobody out. - Personal settings keeps the changing browser's session: it takes the new generation after the commit (keepSessionAfterPasswordChange()). An admin's change (Account › Users, PUT) ends the user's sessions, not the admin's. RememberMeKeysRevokedTest holds every password change and every standing writer to ending the sessions, the rehash to not, and every write of the generation to counting it up by one; SessionSignInPathsTest holds the session's generation to the sign-in and keepSessionAfterPasswordChange(). SessionGenerationIntegrationTest runs two browsers through each writer, the rehash, a session from before the upgrade, a failed read, a failed count and the window before the upgrade; SessionGenerationUpgrade- IntegrationTest runs the rung. tests/live/password-ends-sessions.sh drives it over HTTP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ssions The purge's access statements take 1.9.79's docblock and its offline conversion feeds; the 1.9.79 rung keeps its tables and adds 202_users at the end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…eneration The users API page, the OpenAPI spec and the CLI guide say a new password and a switch-off sign out every browser signed in as the user; the 1.9.79 changelog lines say so, after 1.9.79's own, and #325's line no longer says a signed-in browser stays signed in. tests/live/README.md lists password-ends-sessions.sh, and CLAUDE.md #5 says what the session was. SessionGenerationIntegrationTest also holds a sign-in after a change to the new generation, and an ended session to revoking no key a later sign-in made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
1.9.79's PHPStan job has been red since the merges at 15:46Z. #308 extended ForbidNumericCastOfRequestParamRule to the pages and baselined the casts already there. Three PRs merged beside it each added a cast that its baseline does not count, and each PR passed on its own: - Personal settings (#283) cast the same-IP fallback after checking it is a key of its choices. It now stores 1 for '1' and 0 for '0', the two keys the check admits. - Setup > Offline Conversions (#293) cast a feed id after a regex test. It now reads it with PostedId::read(). - Setup > Traffic Sources (#271) cast the edited account's id to read its stored postbacks. The page's own ownership check compares the raw value in SQL, where MySQL reads '12abc' as 12. So a save from ?edit_ppc_account_id=12abc changed account 12 (one of the account's own, not the one named) and held that account's postbacks. Measured on a live instance: the old page saved the tampered edit onto account 1. The id is now read exactly, and anything else is refused in the page's words before anything is read or written. The baseline counts are unchanged: each file now holds only the casts they counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…ds-sessions 1.9.79's PHPStan job is red on its own tip: three request casts that its merges left. #328 fixes them, and it is merged here so this branch's PHPStan check runs green. When #328 merges into 1.9.79, the commit is already there and this merge adds nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
What is red here, and why it is not this PR's:
Generated by Claude Code |
Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… conflict marker 81c601f (#327's squash) took a merge an agent left unresolved: 202-config/changelogs.json held <<<<<<<, ======= and >>>>>>> around two 1.9.79 entries, so the file the in-app release notes read stopped being JSON. Both entries are kept, 1.9.79's first, as each side wrote it. Nothing CI runs would have refused it: PHP lint reads PHP, and no test decoded the file. NoMergeConflictMarkersTest holds every tracked text file to having no conflict-marker line, and every tracked JSON file to parsing. With the marked file put back, both tests fail on it; the tree is otherwise clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in 1.9.79-changelog-json-markers (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, where both sides added lines: the incoming lines are kept as they are and this branch's follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
@copilot resolve the merge conflicts on this branch. |
Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, where both sides added lines: the incoming lines are kept as they are and this branch's follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
@claude resolve merge conflicts |
|
The conflicts are resolved: 3e54c71 merges 1.9.79 (e7e7300, #332) into this branch.
A merge from the base 81c601f cannot see that rewording on its own, because that base's If Copilot's run pushes after this, check that both copies of that line are not back. Generated by Claude Code |
|
Claude finished @tracking202's task in 17s —— View job I couldn't resolve the merge conflictsI didn't change the branch and pushed nothing.
Ways forward:
If the conflicts are in |
Brings in 1.9.79's new commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Backlog: S2IV
What was wrong
#325 revoked the remember-me keys on a new password, and a browser already signed in stayed signed in. So whoever had used a stolen cookie (or was signed in on a shared computer) kept that session after the owner changed or reset the password.
Measured live on #326's head (
tests/live/password-ends-sessions.sh, below): after a change on Personal settings, a reset,PUT /users/{id}and Account › Users, the user's other browsers stayed signed in. A browser idle through a switch-off went on once the user was switched on again.What changed
202_users.user_session_generation INT UNSIGNED NOT NULL DEFAULT 0, in the table definition (UserTables::users()) and in the 1.9.78 → 1.9.79 rung. The rung change is one entry at the end of its table list, which reconciles202_usersagainst its definition.AUTH::begin_user_session()stores the user's generation in the session. Both sign-ins pass it:LOGIN_SELECTnames the column, and the cookie's read isSELECT *.Prosper202\User\SessionGeneration::bump()counts the generation up by one in the writer's transaction, besideRememberMeKeys::revokeAll(), and returns the new value.PasswordChange::store()(Personal settings, the reset page),p202_account_save_user()(Account › Users),UsersController::update()(PUT /users/{id}, both CLIs),MysqlUserRepository::update().UserDataPurge::deleteUser()).AUTH::upgrade_user_password()).UserStanding::READ, the user read connect.php already makes, now selectsuser_session_generationin the same statement.AUTH::sessionUserStands()ends a session whose stored generation is not the row's, at that request.endSession(): a new session id, a new token, the cookie cleared. It does not revoke the keys again: the change revoked them in its own transaction, and a key made since belongs to a later sign-in. A test holds this.AUTH::keepSessionAfterPasswordChange()). The flash says the other browsers are signed out.PUT) ends that user's sessions, not their own.Decisions
Switch-off bumps too. 1.9.79: a signed-in session ends when its user is switched off or removed #323's standing check ends a session only at a request made while the user is off. A browser that made no request in that window went on once the user was switched on again. Measured:
testASessionIdleThroughASwitchOffIsEndedWhenTheUserIsOnAgain.The bump costs one statement in a write that already revokes the keys. The switch-on does not bump. The removal bumps in its soft delete, so a row restored by hand stays signed out.
A session from before the upgrade is read as generation 0, which is every user's value until their first change, so the upgrade signs nobody out. Ending those sessions once would have signed every user out on upgrade. They end at the user's first change, like any other session.
The window between deploying the files and running the upgrade. In that window the column does not exist.
UserStanding::readWithGeneration()catches only1054 Unknown column 'user_session_generation', reads again without it on that request, and compares nothing. Any other failure, including an unknownuser_deleted, still throws to the error page.connect.phplets past its upgrade redirect. The sign-in page is not among them, soLOGIN_SELECTnaming the column cannot fail there.An admin setting their own password on Account › Users ends their own other sessions, this one included (only Personal settings keeps its session). This follows from the code; I did not run it.
The generation key in the session is written only by
begin_user_session()andkeepSessionAfterPasswordChange()(SessionSignInPathsTest). Written elsewhere, a stale session could be given the current value.A value no sign-in wrote (not a non-negative int) is read as -1, which no row holds, so the session is ended.
Statements per request
Measured as MariaDB
Questions, by the session runner, for user 1. 1.9.79's tip and this branch are the same:This holds for a session holding a generation and for one holding none. The generation rides in the read that was already there. The only extra statement is the re-read without the column, on a request in the window before the upgrade.
Tests
tests/User/RememberMeKeysRevokedTest.php(structural, 63 tests):SessionGeneration::bump(or by counting the generation up in its own statement. The rehash must do neither.user_active/user_deletedmust end the sessions too.user_session_generationmust set it to itself+ 1. Its writers are pinned with why.bump()qualified and imported, another class, a method of the name, counted up in the statement, set to 0, counted down, and+ 2.tests/Auth/SessionSignInPathsTest.php: the session'suser_session_generationis written only bybegin_user_session()andkeepSessionAfterPasswordChange(). Six new shapes, including a page writing it, a page counting it up, another AUTH method writing it, and handing it to AUTH's general writer.tests/Auth/SessionGenerationIntegrationTest.php(@group integration, 12 tests). Two browsers, each request run as the web server runs it, through connect.php, with a browser's next request resuming the session file its last one left.PUTand through Account › Users: the user's browser ends and the admin's does not.PasswordChange::store()andPUT): the password is not stored.tests/Upgrade/SessionGenerationUpgradeIntegrationTest.php(@group integration). The rung runs from a stored 1.9.78 on a202_userswithout the column. Afterwards:int(10) unsigned/int unsigned, NOT NULL, default 0);SHOW CREATE TABLE 202_usersequals the fresh install's, less the AUTO_INCREMENT counter;tests/User/UserManagementSaveTest.php: the edit's statements now include the count and its read-back.tests/Auth/fixtures/session-user-runner.php: gainsP202_SU_SESSION_ID(resume a session),P202_SU_GENERATION, and the session's generation in its result.tests/live/password-ends-sessions.sh: a new live pass with two browsers.Plants: 15 of 15 caught by a named test. Each was planted in the real tree (each file linted first) and restored from a
mktempcopy checked withcmp.SessionSignInPathsTestPasswordChangeends no sessionPUT /usersends no sessionPUT /usersbumps for a password only (not a switch-off)BUMPsets the generation to 0Verification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changedon the branch (PHP 8.3.6, fullvendor/; the schema tier pointed at a scratch database,sxr8_it_test). It runs--since origin/1.9.79rather than the brief's--since origin/1.9.79-remember-me-roles: #326 is merged and 1.9.79 holds it, so 1.9.79 is this branch's base.phpstan-baseline.neonexpects 2prosper202.numericCastOfRequestParamin202-account/account.phpand there are 3. The third is(int)$_POST['user_pref_same_ip_fallback'](line 335), which arrived with 1.9.79's tip:origin/1.9.79'saccount.phphas the same 3 casts, and this branch's diff touches only the password block.phpstan-strict.neonpassed.Tests: 7589, Assertions: 149039, Skipped: 6.Integration (
tests/run-integration-suites.sh, every@group integrationsuite, one per fresh database; MariaDB 10.11 here, MySQL 8.0 in CI): 190 of 192 suites passed (1,813 tests), all four auth-chain suites among them.WebhookDispatchIntegrationTest::testOverlappingRunsSendEachDeliveryOnce("without it: the claim alone"): failed once on a deadlock (1213) in an overlapping-runs case. It passed both times I ran the suite alone, and it touches no file of this change.MysqlLookupRepositoriesIntegrationTest: this sandbox has no memcached extension, as on 1.9.79: a signed-in session ends when its user is switched off or removed #323 to 1.9.79: the first request a remember-me cookie signs in has the user's roles #326.Live (port 8177, database
sxr8_test, PHP 8.3.6, MariaDB 10.11),tests/live/password-ends-sessions.sh:bb1fa6a3), same server and database: 31 passed, 10 failed. The other browser stayed signed in after a change (on the same session id); both stayed signed in after a reset; the user's browser stayed signed in afterPUTand after Account › Users; the idle browser stayed signed in through a switch-off; and the generation (read from the database the branch installed) was never counted up.The auth chain's earlier live passes also run on the merged branch:
session-ends-with-user.sh: 93 passed, 0 failed.remember-me-revoked.sh: 28 passed, 0 failed.password-change-revokes.sh: 41 passed, 0 failed.remember-me-first-request.sh: 13 passed, 0 failed.What ran without the licence, and what could not run
The licence stub is down. I installed the instance with the installer's own steps without the licence form:
install_databases(),InstallAccount::create(), and a REST key.The live passes ran through scratch copies of the committed scripts. The only change in each appends
valid_key|b:1;to the session file after each sign-in, which is what the licence service's answer would have set.remember-me-first-request.shinstead ran unchanged, against a server whose outbound HTTPS goes to a closed port, so the licence check fails open.tests/live/upgrade-equals-install.shdid not run. PHP 7.4 is installed here, but the script's fresh install goes throughinstall-instance.sh, which needs the licence service (CI'slicence-stub.sh, reached by name) to answer "Key valid". The stub is down, and I may not edit/etc/hosts. What stands in for it, for this change:202_usersupgraded from 1.9.78 equals the fresh table, on this server);The rung's other tables are other PRs'.
Also not run:
PUT /users/{id}, which ran);Out of scope, found on the way
user_pref_same_ip_fallbackcast inaccount.phpis over the baseline's count (above).WebhookDispatchIntegrationTest's overlapping-runs case lost a run to a deadlock once in a full integration run.Generated by Claude Code