🔧 PUT-1524: Sanity check file sharing api - #3603
Merged
Salazareo merged 6 commits intoAug 18, 2026
Merged
Conversation
Two ways a share listing could name access that was no longer there. A recipient's listing paired the flat permission read with the list of permissions it asked for by array index. That read drops misses and dedupes its keys, so the two are not positional: one entry's live grant vouched for another entry that had none, and listShared() kept publishing a withdrawn item's name, size and signed thumbnail URL to someone who could no longer open it. Read the permission off the value instead. getShares() had the same gap from the owner's side, with no liveness check at all — a grant withdrawn through /auth/revoke-user-user or an ACL mode change left the index row behind, and the owner was told someone could reach a file they could not. Checked against the grants now, one batched read per distinct holder. Pending invites are not subject to it: they have no grant yet, which is the point of them. Also covers the access-token actor, which reaches the same reach bound as an app through a different arm of the ACL check. No behavior change there — it was correct and untested.
Contributor
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||
A revoke reaches a peer region as a replicated SQL delete plus three invalidation events, one per cache the region owns: the u2u row cache, the flat view and the scan generation. The delete alone changes nothing there, and each cache has a different consequence if its event is lost — 20s for the generation, 5 minutes for the row cache, and forever for the flat view, whose grant-path entries carry no expiry. Nothing covered the whole path end to end. The store tests prove each event is emitted and applied; this proves the result, which is that the recipient stops being able to read. It asserts the read still succeeds after the SQL delete alone, so the test also records why replication is not sufficient on its own.
A masked share path hides which folder an item sits in; it was never the thing deciding who may open it. Nothing checked that at the route level, so the guarantee rested on unit tests of the resolver alone. Reads one shared file through its masked path, then tries the sibling four ways: the shared uuid with the sibling's name, the sibling's own uuid, a `..` back out of the root, and the owner's real path. Worth knowing about this one: it's mutation-checked. Removing the head !== root.name guard in sharePathMask.ts fails it with reachable: /testuser/55dd54c0…/share-http-1df8933e.txt. I verified that specifically because two tests I wrote earlier in this chunk passed with their guards broken — both were vacuous, and I deleted them rather than commit false assurance.
`total` counts the shares recorded for you; items are filtered after the page is read, so a withdrawn grant leaves the count higher than anything paging will yield. The page description already explained the short-page behaviour, but the field read as an exact count and the example printed it as one. The test pins the gap it describes: two shares, one withdrawn outside the index, one item listed and a total of two.
jfcastro92
marked this pull request as ready for review
August 18, 2026 19:25
The outbound queue was cleared before the send, so a peer that timed out took its events with it. Most were survivable — a lost cache invalidation heals when the entry's TTL lapses. A revoke's flat-perm invalidation is not: grant-path entries carry no expiry, so a peer went on serving a withdrawn grant until something else wrote that key. Failed sends now go back on the queue, which the existing flush timer retries. Anything queued since wins over the retry, and the queue is bounded at 10,000 with the oldest dropped first, so a peer that stays down cannot grow it without limit. Each retry is signed at send time, so it is not rejected against the replay window.
Salazareo
approved these changes
Aug 18, 2026
Salazareo
left a comment
Member
There was a problem hiding this comment.
looks good, we should test this on prod tho pointing to latest, ill merge then update deps on prod
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.
Sanity check of the sharing API now that #3553 and #3595 are on main, plus a fix for an externally reported authorization issue that landed while this was in review.
Reported vulnerability: plus-addressing decided who a share reached
Reported against
b795b219a(CWE-863).+is a convention the receiving domain defines, not a rule of SMTP, but the canonical form stripped it on every domain — and share recipient resolution read identity off that form. On a domain wherefinance@andfinance+board@are two different mailboxes, sharing to the second granted the first one's account access to the file.The invite path was worse, and this is not in the report: no account had to exist. An invite to
payroll+contractors@was keyed onpayroll@, so whoever later confirmedpayroll@claimed a file addressed to someone else.Both reproduce, and both are now regression tests.
Fix, in two layers. Sub-addressing is a capability a provider opts into, and the ones that document it are listed — gmail, icloud, outlook, proton, fastmail, zoho. Yahoo stays listed with no rules, so the finding that it makes
+significant isn't rediscovered. Unlisted domains are lowercased and otherwise left alone.Separately, share resolution no longer trusts that: a match that rewrote the local part is honored only on a domain whose semantics we have asserted. Deliberately redundant, so a future edit to the provider list cannot quietly reintroduce a misroute.
Splitting the two folds broke blocking, which is the part worth keeping. An alias we refuse to grant on can still land in the blocker's inbox, so
abuseKeykeeps stripping+everywhere and answers that question alone — anti-abuse stays aggressive, identity stays exact. That separation is the reporter's own closing recommendation, and it turned out to be load-bearing rather than optional.Bugs found by the audit
A recipient's
listShared()kept publishing withdrawn shares.#liveGrantspaired the flat permission read with the permissions it asked for by array index. That read drops misses and dedupes its keys, so the two are not positional — one entry's live grant vouched for another that had none. The withdrawn item's name, size and a signed thumbnail URL stayed visible to someone who could no longer open it. It needs two shares in one page to reproduce, which is why single-share tests never caught it.getShares()had the same gap from the owner's side, with no liveness check at all. A grant withdrawn through/auth/revoke-user-useror an ACL mode change left the index row behind, and the owner was told someone could reach a file they could not. Checked against the grants now, one batched read per distinct holder. Pending invites are exempt — they have no grant yet, which is the point of them.An undelivered broadcast event was dropped.
#flushOutboundEventscleared its queue before sending, so a peer that timed out took its events with it. Most were survivable: a lost cache invalidation heals when the entry's TTL lapses. A revoke's flat-perm invalidation is not — grant-path entries carry no expiry, so a peer went on serving a withdrawn grant indefinitely. Failed sends now go back on the queue for the existing flush timer to retry, bounded at 10,000 with the oldest dropped first, and each retry is signed at send time so it is not rejected against the replay window.Security
Permission-related, per AGENTS.md.
The reported issue is a genuine authorization defect: a share reached an account that was never addressed. The other two are narrower — the ACL was correct throughout, and what failed was a listing naming access that was not there, plus a revoke that could fail to land in another region. The listing one discloses metadata (name, size, signed thumbnail URL) to someone whose grant was withdrawn; the broadcast one is the serious of the pair, because nothing bounds it.
No wire change: no signature, response field or error code is renamed, added or removed. Client-visible behavior changes are exactly the three fixes — withdrawn shares stop being listed, and a plus-addressed share on an unlisted domain now creates an invite for that exact address instead of granting to a different account.
Coverage added where behavior was already correct
ACLService.check. Four tests; no behavior change.totalvs. what paging yields, pinning the approximation the docs change describes.Docs
listShared.mdsaidtotalwas "the total count" and the example printed it as one. It counts recorded shares before the filtering the same page already documents, so it can exceed what paging yields — now described as a headline figure.Notes for reviewers
BroadcastServiceis well outside sharing. It is here because that is where the revoke-propagation defect lives, and the sharing audit surfaced it — but it deserves eyes from whoever owns that file. The alternative fix (giving grant-path flat entries a TTL) was deliberately not taken: it changes permission semantics, where this repairs delivery for everyouter.*event.There are two email canonicalizers.
EmailClient.cleanis a second, independent implementation with its own rule tables, and it still folds+globally. That is not a regression — its only consumers are the blocked-domain check (domain-suffix only) and the invite budget hash, both abuse decisions where the aggressive fold is correct, and neither is identity-bearing. But two near-identically-named functions now have opposite semantics with nothing saying which is which. Worth reconciling in a follow-up; not done here because unifying them changes Yahoo budget behavior, which does not belong in a security fix.Two of #3595's tests asserted the vulnerable behavior and were rewritten. The alias half of
shares to the account behind a case or alias variantis gone — that assertion was the vulnerability. The case half remains.Testing
npm run test:backendgreen (237 files, 6,132 tests),npm run typecheckclean.Regression check on the canonicalization change specifically: login is unaffected (it matches the raw column exactly); existing rows whose
clean_emailwas computed under the old rules still resolve, and fail strict rather than permissive, so no backfill is needed; the blocked-domain list matches on the domain and is untouched. The one intended behavior change is write-path dedup —foo+other@unlisted.comcan now register alongsidefoo@unlisted.com, which is why the provider list was expanded to cover the major providers that were relying on the global default.Every fix is mutation-checked — the guard is broken and the test must fail. That caught two tests of my own that were passing vacuously (
share.issuer_user_idandshare.fsentry_idboth cascade on delete, so the states they set up never existed); both were discarded rather than committed. Separately: the existingdrops an invite whose issuer can no longer share itpasses via that same cascade rather than the code path it names.Also audited with no findings: blocking, invite claim, path masking internals, quota and fan-out. Not audited: the GUI.