From e47ec447b0e550cea3b1fee865389534f93d775a Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 14 Aug 2026 14:32:19 -0400 Subject: [PATCH] fix(share): authorize before resolving the recipient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit share() looked up the recipient in parallel with the entry, before the manage check — and the two failures carried different error codes. Any verified user with a real entry uid could probe arbitrary emails and usernames for account existence, at no quota cost. Resolve the entry, authorize, and only then resolve the recipient: an unauthorized caller now sees the identical safe 404 whether or not the recipient exists. Co-Authored-By: Claude Fable 5 --- .../services/share/ShareService.test.ts | 27 +++++++++++++++++++ src/backend/services/share/ShareService.ts | 12 ++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index f8aad82c7..d9b4aea17 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -219,6 +219,33 @@ describe('ShareService', () => { ).rejects.toMatchObject({ statusCode: 404 }); }); + it('tells a stranger nothing about whether a recipient account exists', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + // The recipient must resolve only after authorization: a caller who + // cannot manage the entry gets the same "no such subject" error for a + // real recipient and a made-up one, so /share cannot be used to probe + // which emails have accounts. + const probe = (email: string) => + share(stranger.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + await expect(probe(recipient.email)).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'subject_does_not_exist', + }); + await expect(probe('nobody@nowhere.test')).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'subject_does_not_exist', + }); + }); + it('revokes access and drops the index row', async () => { const owner = await makeUser(); const recipient = await makeUser(); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index cc5e87034..1b54dbb67 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -203,13 +203,13 @@ export class ShareService extends PuterService { const issuerId = this.#requireUserId(actor); const mode = this.#requireMode(input.mode); - // Independent reads; the authorization check needs only the entry. - const [entry, holder] = await Promise.all([ - this.#resolveEntry(input), - this.#resolveRecipient(input.recipient), - ]); - + // Authorization before recipient resolution: a caller who cannot + // manage the entry must learn nothing from this endpoint — including + // whether an email or username has an account. "Recipient does not + // exist" may only be observed by someone entitled to share. + const entry = await this.#resolveEntry(input); await this.#assertCanManage(actor, entry); + const holder = await this.#resolveRecipient(input.recipient); if (holder.id === issuerId) { throw new HttpError(400, 'cannot share with yourself', {