Repository navigation
Conversation
…er changes survive Login (`addSessionToUser`), `revokeSession`, refresh, logout and the parallel-attempt lockout branch of `incrementLoginAttempts` passed the whole user row, read earlier in the request, to `payload.db.updateOne`. Any change committed between that read and the write (a password change, a field changed by an admin or a hook) was overwritten with the stale values. These writes now send only `sessions` and `updatedAt`. `updatedAt: null` still means "leave the column untouched"; the lockout path keeps writing its own refreshed `updatedAt`. On MongoDB the transactional paths (login, refresh, logout) fail with a WriteConflict instead of overwriting, so the new tests for those paths are skipped there; the lockout path runs without a transaction and is covered on both adapters.
|
Pull Request titles must follow the Conventional Commits specification and have valid scopes. Unknown scope "payload" found in pull request title "fix(payload): write only sessions in session updates so concurrent user changes survive". Scope must match one of: cpa, claude, codemod, db-*, db-d1-sqlite, db-mongodb, db-postgres, db-vercel-postgres, db-sqlite, db-d1-sqlite, drizzle, email-*, email-nodemailer, email-resend, eslint, evals, graphql, kv, kv-redis, live-preview, live-preview-react, live-preview-vue, next, payload-cloud, plugin-cloud, plugin-cloud-storage, plugin-ecommerce, plugin-form-builder, plugin-import-export, plugin-mcp, plugin-multi-tenant, plugin-nested-docs, plugin-redirects, plugin-search, plugin-sentry, plugin-seo, plugin-stripe, richtext-*, richtext-lexical, sdk, skills, storage-*, storage-azure, storage-gcs, storage-r2, storage-vercel-blob, storage-s3, tanstack-start, translations, ui, templates, examples(/(\w|-)+)?, deps. |
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
What?
Session bookkeeping on login, refresh, logout and failed-login lockout wrote the whole user row back from a copy read earlier in the request. A change committed to the user in between (password change, a field changed by an admin or a hook) was silently reverted.
Why?
With
auth.useSessions(the default), these writes pass the in-memory user topayload.db.updateOneasdata:addSessionToUser(login, reset-password)data: userrevokeSession(login error path, reset-password)data: userrefreshOperationdata: { ...user, sessions }logoutOperationdata: userWithSessions(whole row fromfindOne)incrementLoginAttempts, the "parallel failed attempts reached the limit" branchdata: userEvery column of the row is rewritten, so the stale values of every field win over whatever was committed after the read. The
updatedAt = nulltrick on these paths only keeps the timestamp from moving; it does not stop the other columns from being rewritten.Observed downstream (Postgres, 3.90.2): a loop of about 8 logins per second against one account reverted a concurrent password change (the old password worked again), an account block, and a custom field reset. The effect is a security problem as well as a data-loss one: a password reset or block can be undone by anyone who keeps logging in.
How?
These writes now send only the columns they own:
{ sessions, updatedAt }.addSessionToUser,revokeSession, refresh and logout:updatedAt: null(unchanged semantics: leave the column alone).incrementLoginAttempts:updatedAtkeeps the refreshed value it already set.Nothing else changes; the in-memory
usermutations that callers rely on are untouched.main(v4) has the same pattern (addSessionToUser,revokeSession,extendSession,logoutOperationand the same lockout branch inincrementLoginAttempts), so it is affected too. This PR only targets3.x; I can send the matching change formainonce the approach here is agreed, as the files differ.Why the tests missed it
sessionsonly. The tests check that sessions were added, removed or refreshed. No test checks that other fields of the user survive that write.Test coverage added
New
describe('session writes do not overwrite concurrent user changes')intest/auth/int.spec.ts. A helper wrapspayload.db.updateOneand, right before the first write that carriessessions, commits a change through the originalupdateOne(separate transaction), so the race is deterministic rather than timing-based.addSessionToUseraddSessionToUser, the reported symptomrefreshOperationlogoutOperationincrementLoginAttemptsrevokeSessionshares its write shape withaddSessionToUserand is not covered by a separate test; it is only reached from the login/reset-password error paths.Verification
Run on the unfixed source first, to confirm the failure reason, then with the fix.
pnpm test:int:postgres auth/int.spec.ts -t "session writes")null; the old password still logs in)pnpm test:int auth/int.spec.ts -t "session writes")test/auth/int.spec.tswith the fix: Postgres 101 passed / 1 skipped; MongoDB 97 passed / 5 skipped.pnpm test:int:postgres auth(all auth suites): 143 passed / 1 skipped.tsc --noEmitclean forpackages/payload,drizzle,db-mongodb,graphql.HEAD, 12 after); prettier clean.Why 4 of the tests are skipped on MongoDB
Login, refresh and logout write inside the request's transaction. MongoDB aborts a transaction that writes a document changed after the transaction began (
WriteConflict, HTTP 500) instead of overwriting it, both before and after this change. So the lost update cannot be reproduced there with these paths, and the tests skip. The lockout path runs without a transaction and fails/passes on both adapters. The MongoWriteConflicton a login that races an update is a separate behaviour that I did not change.Not verified
dataobject, but I did not execute it there.db-postgreswas not typechecked (its dependencydistis not built in my checkout); the change does not touch it.mainwas inspected but not changed or tested.Notes for review
resetLoginAttemptsalready writes onlylockUntil/loginAttempts.verifyEmail(data: { ...user, _verified: true, ... }) andresetPassword(data: userwithsessions = []). They are token-gated one-shot flows rather than something an attacker can trigger in a loop, but they have the same stale-write shape; happy to follow up if you want them narrowed too.payload.updateclearssessions; a login that read the user before it will still write its ownsessionsarray. That remaining "stale sessions" behaviour is narrower than the full-row overwrite and is not changed here.