mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
7ebc9b0905
consumeRecoveryCode() reads the whole list, filters the used code out, and writes the whole list back. Two requests that both read before either writes each store their own copy, and the second write puts back the code the first removed. So a spent code comes back, and the same code offered twice is accepted twice -- while the method's first line says "each code works exactly once". Nobody gets in through this who was not already holding a valid code, so it is a promise not being kept rather than a door standing open. The promise is worth keeping anyway: it is the whole reason a printed sheet of recovery codes can be crossed off, and it is what makes a code that somebody watched being typed in stop working. The decision now comes from the row as it stands, re-read under a lock inside the transaction that writes it -- the shape SendNotificationDigest already uses to claim the rows it is about to delete. A conditional update, as in PublicShareController's downloads_count and the delivered_at claim in #1692, is the other precedent in the tree, but the column is `encrypted:array`: there is nothing in it a database can compare, so the comparison has to happen after decryption, under something that holds the row while it does. The lock is what makes it atomic against a request arriving at the same moment. The re-read is what makes the decision right, and it is the half a test can show: SQLite ignores lockForUpdate, so the accompanying tests pin the re-read and say so rather than claiming to prove the locking. config/database.php runs MySQL or Postgres in production, and both honour it. Saving through the caller's own instance keeps that instance in step with the row, so a caller cannot go on to decide from a list the database no longer has.