From b76ca7a13f33a5b3b8befdffc24cd83f6dd6372f Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 14 Aug 2026 14:36:32 -0400 Subject: [PATCH] fix(share): only a confirmed email designates a recipient Recipient resolution by email accepted unconfirmed accounts, so pre-registering someone else's address (unconfirmed) was enough to receive shares meant for them once no confirmed account held it. An email now only resolves to an account that has confirmed it; username shares are unaffected. Co-Authored-By: Claude Fable 5 --- .../services/share/ShareConsistency.test.ts | 5 ++- .../services/share/ShareService.test.ts | 32 ++++++++++++++++++- src/backend/services/share/ShareService.ts | 5 ++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/backend/services/share/ShareConsistency.test.ts b/src/backend/services/share/ShareConsistency.test.ts index 42bccf9d9..f12702138 100644 --- a/src/backend/services/share/ShareConsistency.test.ts +++ b/src/backend/services/share/ShareConsistency.test.ts @@ -79,7 +79,10 @@ describe('share consistency across KV, SQL and Redis', () => { const found = await server.stores.user.getByUsername(username); if (!found) throw new Error('test user missing'); const email = `${username}@test.local`; - await server.stores.user.update(found.id, { email }); + await server.stores.user.update(found.id, { + email, + email_confirmed: true, + }); const user = await server.stores.user.getById(found.id, { force: true, }); diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index d9b4aea17..9c8deca3f 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -41,7 +41,10 @@ describe('ShareService', () => { const user = await server.stores.user.getByUsername(username); if (!user) throw new Error('test user missing'); const email = `${username}@test.local`; - await server.stores.user.update(user.id, { email }); + await server.stores.user.update(user.id, { + email, + email_confirmed: true, + }); const fresh = await server.stores.user.getById(user.id, { force: true, }); @@ -202,6 +205,33 @@ describe('ShareService', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); + it('does not resolve an email its account has not confirmed', async () => { + const owner = await makeUser(); + const squatter = await makeUser(); + await server.stores.user.update(squatter.user.id, { + email_confirmed: false, + }); + const file = await makeFile(owner.user); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: squatter.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'user_does_not_exist', + }); + + // A username names exactly one account, confirmed or not. + await share(owner.actor, { + uid: file.uuid, + recipient: { username: squatter.user.username }, + mode: 'read', + }); + }); + it('hides a file from a stranger trying to share it', async () => { const owner = await makeUser(); const stranger = await makeUser(); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 1b54dbb67..540c87cdf 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -661,7 +661,10 @@ export class ShareService extends PuterService { : username ? await this.stores.user.getByUsername(username) : null; - if (!user?.username) { + // An unconfirmed email is a claim, not an identity: resolving it would + // hand the share to whoever registered the address first. + const unconfirmedEmailMatch = Boolean(email) && !user?.email_confirmed; + if (!user?.username || unconfirmedEmailMatch) { throw new HttpError(404, 'Recipient does not exist', { legacyCode: 'user_does_not_exist', });