Skip to content

Commit 0ecac96

Browse files
fix(plugin-sharing, runtime): an email-audience share link answers every caller it does not open for with one refusal (#23471)
Fixes #23372 Clause-②: no — every request refused before is still refused, and every request served before is still served; only which refusal code a caller reads changes. ## What changes The share-link resolve door (`GET /api/v1/share-links/:token/resolve`) has two mounts: plugin-sharing's `registerShareLinkRoutes` and the runtime dispatcher's share-links domain. When `resolveToken` refuses, each mount probes the row to pick the refusal it answers. `resolveToken` refuses an unsatisfied `email` audience before it reads any password, but neither probe had an arm for the `email` audience. So that refusal fell through to the password arm, which knows only whether a password was SENT, and the holder of the correct password read `401 WRONG_PASSWORD`. Both probes now carry an `email` arm after the `signed_in` arm and before the password arm. A live `email`-audience link that did not resolve answers the probe's existing generic refusal, `404 INVALID_OR_EXPIRED`, through the same `invalidOrExpired()` helper the probe already uses for an unknown token. The arm reads neither the email the caller sent nor whether the password is correct. Landing points are as dispatched: the two probe files, the one table that holds both mounts to one order, and one changeset. `resolveToken` is unchanged. ## Before and after The link is live, has the `email` audience with a one-address allowlist, and has a password. Each cell is the status plus `error.code`, measured in-process through both mounts' production handlers on SqlDriver. The two mounts gave identical answers in every cell, before and after. Before (`main` at `7b3d5585`): | email sent / password sent | none | wrong | right | |---|---|---|---| | none | 401 `NEEDS_PASSWORD` | 401 `WRONG_PASSWORD` | 401 `WRONG_PASSWORD` | | off-list | 401 `NEEDS_PASSWORD` | 401 `WRONG_PASSWORD` | 401 `WRONG_PASSWORD` | | allowlisted | 401 `NEEDS_PASSWORD` | 401 `WRONG_PASSWORD` | 200, record | After (this branch): | email sent / password sent | none | wrong | right | |---|---|---|---| | none | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | | off-list | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | | allowlisted | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | 200, record | - A signed-in caller reads the same grid as an anonymous one, before and after. `resolveToken` neither accepts sign-in for an `email` audience nor reads the session's address. - A password-less `email` link answered `404 INVALID_OR_EXPIRED` in every unsatisfied cell before this change, and still does. It still serves an allowlisted email. - After the change, every refusal cell is byte-identical (status, envelope and headers) to the answer for an unknown token. ## The code chosen, and why The code is `404 INVALID_OR_EXPIRED`, which already exists in the ledger. No new code, so Clause ② stays `no`. - **What an allowlisted recipient with a mistyped password reads:** "Share link is invalid, expired, or revoked". The console renders a 404 as "This link is invalid or no longer available." The recipient loses the "Incorrect password" hint they read before. We did not choose that loss; the argument below forces it. They can act on it the only way the door can let them: retry with the email and the password they were given. - **What an off-list caller reads:** the same 404. A password-less `email` link has always answered them with it, and an unknown token answers with it too. Nothing in it points them at a password they cannot use. Alternatives considered and rejected: - **`NEEDS_PASSWORD` for every unresolved cell, or only when no password was sent.** It tells a caller who sent the correct password that a password is required. That is the same misleading claim as the defect, in a softer word. The console never sends `?email=`, so it would loop on its password prompt forever. And it discloses that the link has a password before the audience is satisfied. - **`WRONG_PASSWORD`:** this is the defect itself. - **`SIGN_IN_REQUIRED`:** false here. Signing in does not satisfy an `email` audience. - **`EXPIRED_OR_REVOKED`:** false for a live link. - **A new code:** that would add a ledger entry, make it Clause ② `yes`, and move it to the spec lane. Not taken. ## The oracle argument - **Membership.** The email this audience checks is the caller's own `?email=` claim, and nothing verifies it. Any answer that depended on whether the claimed email is on the allowlist would disclose membership to whoever holds the token. The probe cannot learn whether the audience is satisfied without reading that claim, and the new arm never reads it. So no answer can differ by email: this follows from the code's structure, and two pins assert it. - **Password correctness.** Before the change, the refusal cells already did not differ by correctness; only the served cell did. So the defect was a misleading claim, not an oracle. The before grid measures exactly that. After the change, every refusal cell is one answer. Only the served cell differs, and it needs an allowlisted email and the right password together, which is the link working as intended. - **Password sent or not.** That difference would have been allowed, because it is the caller's own input. The chosen answer does not use it: one refusal for every unresolved cell. That also hides whether the link has a password at all. It is the shape the `signed_in` audience already has: one answer before the audience is satisfied, whatever was sent. - **Why not keep the password arms for an allowlisted email** (triage's "until the audience is satisfied")? For this audience, "satisfied" is a claim, not a fact. Answering an allowlisted email with `NEEDS_PASSWORD` / `WRONG_PASSWORD` while an off-list email reads 404 would be exactly the membership oracle. Nothing here answers by email, so by triage's rule no `[Decision]` card is needed. ## Pins All pins live in `packages/runtime/src/domains/share-links-refusal-order.test.ts`, the one table that holds both mounts. Every case runs once per mount. - **14 new rows:** the 9 cells of the anonymous grid, 3 signed-in cells, and 2 cells for a password-less `email` link. Each asserts `status` and `error.code`, or the record for a served cell. - **3 new whole-answer pins:** - no password oracle (no email or an off-list email reads one answer, whatever password was sent); - no allowlist-membership oracle (an allowlisted email without the right password reads what an off-list email reads); - the refusal is the answer an unknown token and a password-less `email` link give. - **Red on `main`'s behaviour** (pins committed before the probes changed): 24 failed, 36 passed, 12 per mount. The membership pin was green on `main`, as measured: today has no membership oracle. - **Green:** 60 of 60. ## Ablations Each leg went through `scripts/ablation-replace.mjs`: the anchor must hit, and each restore is proven by the blob equalling HEAD and `git diff HEAD` being empty. The runtime test resolves `@objectstack/plugin-sharing` through `exports` to `dist/`. So the plugin mount was rebuilt on both legs, and `scripts/ablation-dist-preflight.mjs` proved the mutation was in `dist/` and then gone from it. | Mount | Mutation | Red | Read | |---|---|---|---| | dispatcher twin | A1: the `email` arm removed | 12 (10 rows, 2 pins); plugin mount all green | no-password-oracle pin: one cell expected `NEEDS_PASSWORD`, another received `WRONG_PASSWORD` | | dispatcher twin | A2: the arm answers by email (the oracle) | 5 (3 allowlisted rows, membership pin, generic-refusal pin) | membership pin: allowlisted with no password read 401 `NEEDS_PASSWORD`, off-list read 404 | | plugin route | A1 | 12, all plugin route | same as the twin | | plugin route | A2 | 5, all plugin route | same as the twin | The predicted directions held. A1 leaves the membership pin green, because removing the arm restores today's behaviour, which has no membership oracle. A2 is the leg that proves the membership pin can fail. ## Verification (head `33934882`) - `@objectstack/plugin-sharing`: vitest 44 files, 1055 passed. `typecheck` passed (including `check:test-typecheck`). - `@objectstack/runtime`: vitest `local` 373 files, 5489 passed, 19 skipped. `repo` 3 files, 808 passed. `typecheck` passed. `tsc --listFiles` counts the test file in the test program, with no errors naming it. - Gates: `dispatch-gates --ran` reconciles 68 derived, 67 run, 1 NOT MEASURED, 0 UNRUN. The six roster families the derivation marks for these paths also passed: `check:route-ledger-census`, `check:tenant-chokepoint`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check-changeset-fixed`. - NOT MEASURED: `check:dual-build-cjs-loads`. It refuses until every workspace package has a `dist/`, and that full build is CI's. As a narrowed stand-in, the CJS entries of the two packages this diff touches load under `require()`. - Lint, narrowed to the 3 changed TS files: eslint `--format json` reports 3 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting, so this diff cannot change the verdict on any file it does not touch. The repository-wide run is CI's. ## Acceptance notes - **API and SDK callers** of an `email` link with a password now send the email and the password together. The door no longer prompts for the password first: an allowlisted email with no password used to read `NEEDS_PASSWORD`. The changeset says so. - **The console** never sends `?email=` and builds no `email` audience in its share dialog. An `email` link opened there now shows the "invalid" page at once, instead of a password prompt that could never succeed. - **Handed to the seat, not changed here:** the sibling `/messages` door does not forward every audience input that `/resolve` honours. It was measured in-process; the details are in the dev report on the card. Related: #23328 / PR #23369 (the `signed_in` order this builds on). --- _Generated by [Claude Code](https://claude.ai/code/session_01LxHPTb1LxL7a6WkQbALKnD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87d0619 commit 0ecac96

4 files changed

Lines changed: 172 additions & 10 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(plugin-sharing, runtime): a share link limited to an email allowlist answers every caller it does not open for with `404 INVALID_OR_EXPIRED`, whatever email and password were sent
7+
8+
Clause-②: no — every request refused before is still refused, and every request served before is still served; only which refusal code a caller reads changes.
9+
10+
A share link can carry a password and the `email` audience together. For such a link, the share-link resolve door answered a caller it did not open for from the password, never the audience: `401 NEEDS_PASSWORD` with no password, and `401 WRONG_PASSWORD` with any password, the correct one included, when the email sent was missing or not on the allowlist. A caller with the right password read "Incorrect password".
11+
12+
- **An `email` link, any caller it does not open for:** `404 INVALID_OR_EXPIRED`, with or without an email, with no password, a wrong one or the correct one, signed in or not. It is the answer a password-less `email` link already gave and an unknown token gives, so it tells the caller neither whether the email is on the allowlist, nor whether the password is right, nor whether the link has a password. The door does not read the email to choose its answer.
13+
- **Served, unchanged:** an allowlisted email with the correct password (or, on a link without a password, an allowlisted email alone) still opens the record. An API or SDK caller sends both together; the door no longer prompts for the password first.
14+
- **Unchanged:** links with any other audience, including the `signed_in` order (`401 SIGN_IN_REQUIRED` before sign-in, the password arms after), a `public` link's `NEEDS_PASSWORD` / `WRONG_PASSWORD`, and the expired-or-revoked `410 EXPIRED_OR_REVOKED`.
15+
- Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain.

‎packages/plugins/plugin-sharing/src/share-link-routes.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,22 @@ export function registerShareLinkRoutes(
463463
if (row.audience === 'signed_in' && !signedInUserId) {
464464
return sendError(res, 401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
465465
}
466+
// [#23372] An `email` audience is judged before the password too, and
467+
// its refusal is the generic one, for every caller the link did not
468+
// resolve for. Its satisfaction is the caller's own `?email=` claim,
469+
// not a verified identity, and this probe deliberately never reads
470+
// it: an answer that differed for an allowlisted email — a password
471+
// prompt for it, the generic refusal for any other — would let
472+
// anyone holding the token test addresses against the allowlist.
473+
// Nor may the password arm below answer: `resolveToken` refuses the
474+
// audience before it verifies any password, so that arm knows only
475+
// whether one was SENT, and told the holder of the CORRECT password
476+
// `WRONG_PASSWORD`. So the caller reads what a password-less `email`
477+
// link and an unknown token answer, whatever email and password it
478+
// sent, signed in or not; only the record itself — an allowlisted
479+
// email AND the right password — differs. Same arm in the dispatcher
480+
// twin, held to this order by the same table.
481+
if (row.audience === 'email') return invalidOrExpired();
466482
// [#21197] `password_hash` is `internal`, so the probe row comes back
467483
// without it; read off the row, every protected link would answer
468484
// "invalid" here instead of prompting for its password. Recovered

‎packages/runtime/src/domains/share-links-refusal-order.test.ts‎

Lines changed: 126 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@
3232
* the password arms to an anonymous caller, an unprotected `public` link
3333
* still serves, and a revoked link still answers the 410 whoever asks.
3434
*
35+
* ## The email audience (#23372)
36+
*
37+
* For a live link whose audience is `email`, every caller the link does not
38+
* resolve for is answered `404 INVALID_OR_EXPIRED` — whatever email and
39+
* whatever password it sent, signed in or not. The email is the caller's own
40+
* claim (`?email=`), never a verified identity, so the probe may not answer
41+
* by it: an allowlisted email with a wrong password that read differently
42+
* from an off-list email would let anyone holding the token test addresses
43+
* against the allowlist. Nor may it answer from the password: `resolveToken`
44+
* refuses the audience before it verifies any password, so the password arm
45+
* could only report whether one was SENT — `WRONG_PASSWORD` for the correct
46+
* one. The refusal is the one a password-less `email` link already answered
47+
* and an unknown token answers, so it tells the holder neither whether the
48+
* email is on the list, nor whether the password is right, nor whether the
49+
* link has one. Only the record itself — an allowlisted email AND the right
50+
* password — differs.
51+
*
3552
* ## What is real here
3653
*
3754
* Both mounts' production entries over ONE `ObjectQL` on
@@ -61,11 +78,13 @@ const RECORD = 'doc_1';
6178
const VIEWER = 'usr_viewer';
6279
const PASSWORD = 'correct horse 23328';
6380
const WRONG = 'not the password 23328';
81+
const RECIPIENT = 'recipient-23372@example.test';
82+
const STRANGER = 'stranger-23372@example.test';
6483

6584
const TARGET = {
6685
name: OBJECT,
6786
label: 'Refusal Doc',
68-
publicSharing: { enabled: true, allowedAudiences: ['public', 'signed_in'], allowedPermissions: ['view'] },
87+
publicSharing: { enabled: true, allowedAudiences: ['public', 'signed_in', 'email'], allowedPermissions: ['view'] },
6988
fields: {
7089
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
7190
title: { name: 'title', label: 'Title', type: 'text' },
@@ -75,7 +94,15 @@ const TARGET = {
7594
type Mount = 'plugin route' | 'dispatcher twin';
7695
type Caller = 'anonymous' | 'signed in';
7796
type Presented = 'no password' | 'a wrong password' | 'the correct password';
78-
type LinkKey = 'signed_in + password' | 'public + password' | 'public' | 'revoked signed_in + password';
97+
type Email = 'no email' | 'an off-list email' | 'an allowlisted email';
98+
type LinkKey =
99+
| 'signed_in + password'
100+
| 'public + password'
101+
| 'public'
102+
| 'revoked signed_in + password'
103+
| 'email + password'
104+
| 'email'
105+
| 'unknown token';
79106
type Answer = { status: number; body: any; headers: Record<string, string> };
80107

81108
const MOUNTS: readonly Mount[] = ['plugin route', 'dispatcher twin'];
@@ -84,6 +111,12 @@ const PRESENTED: Record<Presented, string | undefined> = {
84111
'a wrong password': WRONG,
85112
'the correct password': PASSWORD,
86113
};
114+
/** The `?email=` query parameter, as an API or SDK caller sends it. */
115+
const EMAILS: Record<Email, string | undefined> = {
116+
'no email': undefined,
117+
'an off-list email': STRANGER,
118+
'an allowlisted email': RECIPIENT,
119+
};
87120

88121
/** The dispatcher's REAL thrown-error mapper, as the sibling suites borrow it. */
89122
const realErrorFromThrown = (() => {
@@ -123,7 +156,7 @@ const CALLERS: Record<Caller, ExecutionContext> = {
123156

124157
let engine: ObjectQL;
125158
const tokens = {} as Record<LinkKey, string>;
126-
let open: (mount: Mount, link: LinkKey, caller: Caller, presented: Presented) => Promise<Answer>;
159+
let open: (mount: Mount, link: LinkKey, caller: Caller, presented: Presented, email?: Email) => Promise<Answer>;
127160

128161
beforeAll(async () => {
129162
engine = new ObjectQL();
@@ -138,14 +171,27 @@ beforeAll(async () => {
138171

139172
const svc = new ShareLinkService({ engine: engine as any });
140173
const creator = { isSystem: true, userId: 'usr_creator' } as any;
141-
const mint = async (audience: 'public' | 'signed_in', password?: string) =>
142-
svc.createLink({ object: OBJECT, recordId: RECORD, audience, ...(password ? { password } : {}) }, creator);
174+
const mint = async (audience: 'public' | 'signed_in' | 'email', password?: string) =>
175+
svc.createLink(
176+
{
177+
object: OBJECT,
178+
recordId: RECORD,
179+
audience,
180+
...(password ? { password } : {}),
181+
...(audience === 'email' ? { emailAllowlist: [RECIPIENT] } : {}),
182+
},
183+
creator,
184+
);
143185
tokens['signed_in + password'] = (await mint('signed_in', PASSWORD)).token;
144186
tokens['public + password'] = (await mint('public', PASSWORD)).token;
145187
tokens.public = (await mint('public')).token;
146188
const revoked = await mint('signed_in', PASSWORD);
147189
await svc.revokeLink(revoked.id, creator);
148190
tokens['revoked signed_in + password'] = revoked.token;
191+
tokens['email + password'] = (await mint('email', PASSWORD)).token;
192+
tokens.email = (await mint('email')).token;
193+
// Long enough to reach the row lookup; no row carries it.
194+
tokens['unknown token'] = 'never-minted-token-23372';
149195

150196
// The plugin mount, once per caller: the family door resolves the caller
151197
// through `contextFromRequest`, exactly as production wires it.
@@ -159,12 +205,14 @@ beforeAll(async () => {
159205
}
160206
const deps = makeDeps(engine, svc);
161207

162-
open = async (mount, link, caller, presented) => {
208+
open = async (mount, link, caller, presented, email = 'no email') => {
163209
const token = tokens[link];
164210
const password = PRESENTED[presented];
165211
const headers: Record<string, string> = password === undefined ? {} : { 'x-share-password': password };
212+
const address = EMAILS[email];
213+
const query: Record<string, string> = address === undefined ? {} : { email: address };
166214
if (mount === 'dispatcher twin') {
167-
const res = await handleShareLinksRequest(deps, `/${token}/resolve`, 'GET', undefined, {}, {
215+
const res = await handleShareLinksRequest(deps, `/${token}/resolve`, 'GET', undefined, query, {
168216
executionContext: CALLERS[caller],
169217
request: { headers },
170218
} as unknown as HttpProtocolContext);
@@ -181,7 +229,7 @@ beforeAll(async () => {
181229
};
182230
const req: IHttpRequest = {
183231
params: { token },
184-
query: {},
232+
query,
185233
body: undefined,
186234
headers,
187235
method: 'GET',
@@ -203,6 +251,8 @@ afterAll(async () => {
203251
interface Row {
204252
link: LinkKey;
205253
caller: Caller;
254+
/** The `?email=` sent; `no email` when the row leaves it out. */
255+
email?: Email;
206256
presented: Presented;
207257
status: number;
208258
/** The refusal's `error.code`; absent on a row that serves the record. */
@@ -228,11 +278,37 @@ const TABLE: readonly Row[] = [
228278
// Control: the 410 arm is not reordered — a revoked link answers it whoever asks.
229279
{ link: 'revoked signed_in + password', caller: 'anonymous', presented: 'the correct password', status: 410, code: 'EXPIRED_OR_REVOKED' },
230280
{ link: 'revoked signed_in + password', caller: 'signed in', presented: 'the correct password', status: 410, code: 'EXPIRED_OR_REVOKED' },
281+
// [#23372] A password AND audience `email`: until the link resolves, one
282+
// refusal, whatever email and whatever password were sent.
283+
{ link: 'email + password', caller: 'anonymous', email: 'no email', presented: 'no password', status: 404, code: 'INVALID_OR_EXPIRED' },
284+
{ link: 'email + password', caller: 'anonymous', email: 'no email', presented: 'a wrong password', status: 404, code: 'INVALID_OR_EXPIRED' },
285+
{ link: 'email + password', caller: 'anonymous', email: 'no email', presented: 'the correct password', status: 404, code: 'INVALID_OR_EXPIRED' },
286+
{ link: 'email + password', caller: 'anonymous', email: 'an off-list email', presented: 'no password', status: 404, code: 'INVALID_OR_EXPIRED' },
287+
{ link: 'email + password', caller: 'anonymous', email: 'an off-list email', presented: 'a wrong password', status: 404, code: 'INVALID_OR_EXPIRED' },
288+
{ link: 'email + password', caller: 'anonymous', email: 'an off-list email', presented: 'the correct password', status: 404, code: 'INVALID_OR_EXPIRED' },
289+
{ link: 'email + password', caller: 'anonymous', email: 'an allowlisted email', presented: 'no password', status: 404, code: 'INVALID_OR_EXPIRED' },
290+
{ link: 'email + password', caller: 'anonymous', email: 'an allowlisted email', presented: 'a wrong password', status: 404, code: 'INVALID_OR_EXPIRED' },
291+
{ link: 'email + password', caller: 'anonymous', email: 'an allowlisted email', presented: 'the correct password', status: 200 },
292+
// Sign-in neither satisfies an `email` audience nor changes its refusal.
293+
{ link: 'email + password', caller: 'signed in', email: 'an off-list email', presented: 'the correct password', status: 404, code: 'INVALID_OR_EXPIRED' },
294+
{ link: 'email + password', caller: 'signed in', email: 'an allowlisted email', presented: 'a wrong password', status: 404, code: 'INVALID_OR_EXPIRED' },
295+
{ link: 'email + password', caller: 'signed in', email: 'an allowlisted email', presented: 'the correct password', status: 200 },
296+
// Control: a password-less `email` link refuses an off-list email the same
297+
// way, and serves an allowlisted one.
298+
{ link: 'email', caller: 'anonymous', email: 'an off-list email', presented: 'no password', status: 404, code: 'INVALID_OR_EXPIRED' },
299+
{ link: 'email', caller: 'anonymous', email: 'an allowlisted email', presented: 'no password', status: 200 },
231300
];
232301

302+
/** Every row names the email it sent, so each title reads the whole request. */
303+
const ROWS: readonly (Row & { email: Email })[] = TABLE.map((row) => ({ email: 'no email' as const, ...row }));
304+
305+
const CALLER_KEYS = Object.keys(CALLERS) as Caller[];
306+
const EMAIL_KEYS = Object.keys(EMAILS) as Email[];
307+
const PRESENTED_KEYS = Object.keys(PRESENTED) as Presented[];
308+
233309
describe.each(MOUNTS)('[#23328] share-link resolve refusal order — the %s', (mount) => {
234-
it.each(TABLE)('$link, $caller, $presented → $status $code', async (row) => {
235-
const answer = await open(mount, row.link, row.caller, row.presented);
310+
it.each(ROWS)('$link, $caller, $email, $presented → $status $code', async (row) => {
311+
const answer = await open(mount, row.link, row.caller, row.presented, row.email);
236312
const label = `${mount}: ${JSON.stringify(answer.body)}`;
237313
expect(answer.status, label).toBe(row.status);
238314
if (row.code) {
@@ -251,4 +327,44 @@ describe.each(MOUNTS)('[#23328] share-link resolve refusal order — the %s', (m
251327
expect(correct, 'the correct password against a wrong one').toEqual(wrong);
252328
expect(correct, 'the correct password against none').toEqual(none);
253329
});
330+
331+
// [#23372] The email audience. Each pin compares whole answers — status,
332+
// envelope and headers — against one reference, so any difference a caller
333+
// could read is a difference here.
334+
it('[#23372] email audience, no password oracle: no email or an off-list email reads one answer whatever password was sent', async () => {
335+
const reference = await open(mount, 'email + password', 'anonymous', 'no password', 'no email');
336+
for (const caller of CALLER_KEYS) {
337+
for (const email of ['no email', 'an off-list email'] as const) {
338+
for (const presented of PRESENTED_KEYS) {
339+
const answer = await open(mount, 'email + password', caller, presented, email);
340+
expect(answer, `${caller}, ${email}, ${presented}`).toEqual(reference);
341+
}
342+
}
343+
}
344+
});
345+
346+
it('[#23372] email audience, no allowlist-membership oracle: an allowlisted email without the right password reads what an off-list email reads', async () => {
347+
for (const caller of CALLER_KEYS) {
348+
for (const presented of ['no password', 'a wrong password'] as const) {
349+
const offList = await open(mount, 'email + password', caller, presented, 'an off-list email');
350+
const allowlisted = await open(mount, 'email + password', caller, presented, 'an allowlisted email');
351+
expect(allowlisted, `${caller}, ${presented}`).toEqual(offList);
352+
}
353+
}
354+
});
355+
356+
it('[#23372] email audience: the refusal is the one an unknown token and a password-less email link answer', async () => {
357+
const unknown = await open(mount, 'unknown token', 'anonymous', 'no password');
358+
const passwordless = await open(mount, 'email', 'anonymous', 'no password', 'an off-list email');
359+
expect(passwordless, 'a password-less email link against an unknown token').toEqual(unknown);
360+
for (const caller of CALLER_KEYS) {
361+
for (const email of EMAIL_KEYS) {
362+
for (const presented of PRESENTED_KEYS) {
363+
if (email === 'an allowlisted email' && presented === 'the correct password') continue; // served
364+
const answer = await open(mount, 'email + password', caller, presented, email);
365+
expect(answer, `${caller}, ${email}, ${presented}`).toEqual(unknown);
366+
}
367+
}
368+
}
369+
});
254370
});

‎packages/runtime/src/domains/share-links.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,21 @@ async function handleShareLinksRequestBody(
323323
if (live && row.audience === 'signed_in' && !signedInUserId) {
324324
return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
325325
}
326+
// [#23372] An `email` audience is judged before the password
327+
// too, and its refusal is the generic one, for every caller the
328+
// link did not resolve for. Its satisfaction is the caller's
329+
// own `?email=` claim, not a verified identity, and this probe
330+
// deliberately never reads it: an answer that differed for an
331+
// allowlisted email would let anyone holding the token test
332+
// addresses against the allowlist. Nor may the password arm
333+
// below answer: `resolveToken` refuses the audience before it
334+
// verifies any password, so that arm knows only whether one was
335+
// SENT, and told the holder of the CORRECT password
336+
// `WRONG_PASSWORD`. So the caller reads what a password-less
337+
// `email` link and an unknown token answer, whatever email and
338+
// password it sent, signed in or not. Same arm in the
339+
// plugin-sharing twin, held to this order by the same table.
340+
if (live && row.audience === 'email') return invalidOrExpired();
326341
// [#21197] `sys_share_link.password_hash` is `internal: true`, so the
327342
// probe row above comes back WITHOUT it; read off the row, every
328343
// protected link would answer the unknown-token shape and the

0 commit comments

Comments
 (0)