fix(share): authorize before resolving the recipient

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 <noreply@anthropic.com>
This commit is contained in:
Juan Castro
2026-08-14 14:32:19 -04:00
co-authored by Claude Fable 5
parent 5f440333cd
commit e47ec447b0
2 changed files with 33 additions and 6 deletions
@@ -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();
+6 -6
View File
@@ -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', {