Make spending a public link's view the thing that guards it - #813
Merged
Conversation
A public link issued for a single view could be followed twice.
The counter increment was already atomic — `countViews + 1` in SQL, which is
what stops a lost update — but the limit was checked in PHP, against a row that
had already been read:
&& $publicLink->getCountViews() < $publicLink->getMaxCountViews() // read
$this->publicLinkService->addLinkView($publicLink); // then write
Two requests arriving together both read `countViews = max - 1`, both pass, and
both are served. An atomic increment keeps the counter right; it never stopped
the limit being exceeded.
`addLinkView()` now carries both conditions — `COALESCE(countViews, 0) <
maxCountViews AND dateExpire > :now` — and reports whether a row was affected,
so the moment the link is charged for and the moment it is handed over are the
same moment. COALESCE because `countViews` is nullable, and a NULL comparison
would refuse every link nobody had followed yet.
The password-reset flow already does exactly this: `toggleUsedByHash()` is a
conditional update that throws when it affects nothing, before the password is
changed. The pattern was in the codebase; this path predated it.
The race is pinned without concurrency. A second simultaneous request is
precisely a process holding a stale copy of the row, so the test reads the link,
exhausts it through the normal path, and presents that copy — which under the
old code still said "views remaining" and spent the link past its limit.
Two refusal tests in ViewLinkRefusalsTest broke, correctly: they run on the
mocked-database harness, which answers every update with one row affected, so
with the check in SQL the double stopped refusing anything. It now answers the
way a server would. The link read moved into the same closure, because
`databaseQueryResolver` is consulted first and short-circuits the mapper ones.
The `useInfo` list is still assembled from the row as read, so two views landing
together record one entry between them. The counter is exact — that is what
decides access — while the list is a log. Recorded at the call site.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A public link issued for a single view could be followed twice.
The counter increment was already atomic —
countViews + 1in SQL, which is what stops a lost update — but the limit was checked in PHP, against a row that had already been read:Two requests arriving together both read
countViews = max - 1, both pass, and both are served. An atomic increment prevents the counter going wrong; it never prevented exceeding the limit.The guard moves into the update
addLinkView()now carries both conditions:and reports whether a row was affected. The controller serves only if the view was actually spent, so the moment the link is charged for and the moment it is handed over are the same moment.
COALESCEbecausecountViewsis nullable: aNULLwould make the comparisonNULLand refuse every link nobody had followed yet.The password-reset flow already does exactly this —
toggleUsedByHash()is a conditional update (… AND used = 0) that throws when it affects nothing, before the password is changed. The pattern was in the codebase; this path predated it.Tests
The race is pinned deterministically, without concurrency. A second simultaneous request is precisely a process holding a stale copy of the row, so the test reads the link, exhausts it through the normal path, and then presents that copy. Under the old code the copy still said "views remaining" and spent the link past its limit.
The same for an expiry that passes while a request is in flight.
followLink()in the round-trip test replicatesViewLinkController, so it follows the controller's new shape — the guard is the return value of spending the view, not a pair of comparisons before it.Reverting the SQL guard fails four tests, one of which shows an exhausted link handing back the plaintext password.
A harness note
Moving the guard out of PHP broke two refusal tests in
ViewLinkRefusalsTest, correctly: they run on the mocked-database harness, which answers every update with "one row affected", so with the check now in SQL the double stopped refusing anything. The double now answers the way a server would. The link read had to move into the same closure, becausedatabaseQueryResolveris consulted first and short-circuits the mapper resolvers.Known limit, left in place
The
useInfousage list is still assembled from the row as it was read, so two views landing together record one entry between them. The counter is exact — that is what decides access — while the list is a log. Recorded at the call site.3965 unit tests pass; PHPStan level 6 on
srcand PHPCS clean.