Skip to content

Commit 28e294f

Browse files
fix(plugin-sharing, runtime): a password-protected signed_in share link answers an anonymous visitor SIGN_IN_REQUIRED, whatever password was sent (#23369)
Fixes #23328 Clause-②: no — every input refused today is still refused; only which refusal code an anonymous caller reads changes. Nothing is admitted that is refused today. ⛔ If the change would admit anything refused today, or make the answer differ by whether the password was correct before sign-in (an anonymous password oracle), the dev stops and reports `needs_decision`. ## What changes When `resolveToken` refuses, the share-link resolve door probes the link row to name the refusal. For a live link with both a password and audience `signed_in`, that probe answered the password arm first. But `resolveToken` had refused the anonymous caller on the audience before verifying any password, so the probe knew only whether a password was **sent**. The visitor with the correct password read `WRONG_PASSWORD`, and the console's shared-record page said "Wrong password." and never offered sign-in. Both mounts now judge the audience first. Before sign-in the probe asks the audience and never the password: | Caller | No password | Wrong password | Correct password | |---|---|---|---| | anonymous, `signed_in` + password link | `401 SIGN_IN_REQUIRED` | `401 SIGN_IN_REQUIRED` | `401 SIGN_IN_REQUIRED` | | signed in, same link | `401 NEEDS_PASSWORD` | `401 WRONG_PASSWORD` | `200` | - **Mounts.** `registerShareLinkRoutes` (`plugin-sharing/src/share-link-routes.ts`) and the dispatcher twin `handleShareLinksRequest` (`runtime/src/domains/share-links.ts`). - **Unchanged.** Every other arm keeps its order. The switched-off/unknown `404 INVALID_OR_EXPIRED` and the `410 EXPIRED_OR_REVOKED` are decided outside the live-link block this edit touches. A non-`signed_in` link with a password still answers the password arms to an anonymous caller. `resolveToken` is untouched: the cause was in the two probes, not in the service. - **Fewer reads.** For an anonymous caller of a `signed_in` link, the probe now refuses before it reads the internal `password_hash` at all. - **Oracle-free by construction.** An anonymous caller's answer cannot depend on the password, because the arm that answers it reads none. - **Console.** objectui's landing page already maps `SIGN_IN_REQUIRED` to its sign-in state (`apps/console/src/pages/shared-record-shape.ts`, `RESOLVE_GATE_BY_CODE`). That code is unchanged at objectui `origin/main` `2b06a91` and at the pin `1fb1ced362`. After sign-in, the page receives `NEEDS_PASSWORD` and shows its password prompt. No objectui edit. ## One rule, two mounts: why the twin keeps its own copy The seat's direction was to put the order in one helper in `plugin-sharing` that the twin consumes, *if the twin can import `plugin-sharing` today*. It cannot: `@objectstack/plugin-sharing` is a **devDependency** of `@objectstack/runtime` (`packages/runtime/package.json`), and no non-test file under `packages/runtime/src` imports it. Consuming a helper from it would add a production dependency edge, so no dependency was added. Following the direction's fallback, the two copies are kept in step, each comment names the other, and **one shared table test pins both mounts**. ## The pin `packages/runtime/src/domains/share-links-refusal-order.test.ts`: one table, `describe.each` over both mounts' production entries. It runs over one `ObjectQL` on `@objectstack/driver-sql` + better-sqlite3 with the shipped `sys_share_link` (internal `password_hash`), and one `ShareLinkService` with the production password hasher. The password is sent the way the console sends it. Refusals assert the ADR-0112 envelope (`status` + `error.code`, `success: false`). - `signed_in` + password, anonymous: no, wrong and correct password all `401 SIGN_IN_REQUIRED`. - `signed_in` + password, signed in: `NEEDS_PASSWORD`, `WRONG_PASSWORD`, `200` with the record. - The oracle property, as its own case: the anonymous answers for no, a wrong and the correct password are deep-equal (status, body, headers). - Controls: a `public` link with a password answers anonymous `NEEDS_PASSWORD` / `WRONG_PASSWORD` / `200`; an unprotected `public` link serves; a revoked link answers `410 EXPIRED_OR_REVOKED` to anonymous and signed-in callers alike. **Measured before the fix** (test committed first, run on the unmodified tree at `ea17bb7a36`, both mounts): anonymous with no password answered `401 NEEDS_PASSWORD`; with a wrong one **and with the correct one** `401 WRONG_PASSWORD`. The signed-in rows and all controls already passed (8 failed / 18 passed). Today's order therefore had no correct-vs-wrong oracle, only the wrong code. The card's hypotheses H1 (on a real SqlDriver) and H2 (the twin, now run rather than read) both hold. ## Negative-pin ablation (two mutations, both mounts, then restored) Both legs ran on the committed fix (`HEAD` `a6992eee30`), through `scripts/ablation-replace.mjs` (anchor hit exactly once per file, blob changed), with a `trap` restore on absolute paths. Each leg rebuilt `plugin-sharing` and proved the marker reached `dist/` with `scripts/ablation-dist-preflight.mjs` before reading the result. The runtime test reads that package through `dist/`. **Leg 1: today's password-first order restored on both mounts** (marker `ablation23328PasswordFirst`, present in 2 built files): ```text × 'signed_in + password', 'anonymous', 'no password' → 401 'SIGN_IN_REQUIRED' × 'signed_in + password', 'anonymous', 'a wrong password' → 401 'SIGN_IN_REQUIRED' × 'signed_in + password', 'anonymous', 'the correct password' → 401 'SIGN_IN_REQUIRED' × no anonymous password oracle: no, a wrong and the correct password read one identical answer (the same four on the dispatcher twin) AssertionError: plugin route: {"success":false,"error":{"code":"WRONG_PASSWORD","message":"Incorrect password"}}: expected 'WRONG_PASSWORD' to be 'SIGN_IN_REQUIRED' AssertionError: the correct password against none: expected { status: 401, … } to deeply equal { status: 401, … } Tests 8 failed | 18 passed (26) ``` The direction observed here is not the template's. Under today's order the oracle case goes red on its **"against none"** leg, because a wrong and the correct password both read `WRONG_PASSWORD`. Its **"correct against wrong"** leg was not exercised. So: **Leg 2: an oracle-creating anonymous arm on both mounts** (marker `ablation23328Oracle`, present in 2 built files). The anonymous `signed_in` arm verifies the presented password through `resolveToken` and answers `WRONG_PASSWORD` when it fails, `SIGN_IN_REQUIRED` when it succeeds. That is the plausible "helpful" fix the card warns against: ```text × 'signed_in + password', 'anonymous', 'a wrong password' → 401 'SIGN_IN_REQUIRED' × no anonymous password oracle: no, a wrong and the correct password read one identical answer (the same two on the dispatcher twin) AssertionError: the correct password against a wrong one: expected { status: 401, … } to deeply equal { status: 401, … } Tests 4 failed | 22 passed (26) ``` **Restore, both legs:** `ablation-replace --restore` per file: blob == `HEAD` blob (`60b20243e310` routes, `04e3629ed6fb` twin) and `git diff HEAD` empty. The `plugin-sharing` rebuild then passed preflight `--absent` (marker absent from all 6 built files; whole tree clean against `HEAD`), and the table read `26 passed (26)` again. No ablation file is left in the tree. ## Verification Everything below ran on `HEAD` `a6992eee30`, the final commit. `origin/main` is `18cd410dc4`, unchanged since the branch point. - **plugin-sharing.** `pnpm --filter @objectstack/plugin-sharing test`: 44 files, 1055 passed. `run typecheck` (tsc, the scripts project and the test layer): exit 0. - **runtime tests.** `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: 372 files, 5402 passed, 19 skipped. `--project repo`: 3 files, 808 passed. - **runtime typecheck.** `run typecheck`: exit 0. The new test is in the test-layer program: `tsc -p tsconfig.test.json --listFiles` has 1 hit and 0 errors in that file; the 190 ledgered errors are unchanged. - **The pin.** `share-links-refusal-order.test.ts`: 26 passed (26). - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths; change set from the merge base) derived 68 commands, and all 68 ran with exit 0. - `check:i18n` and `check:dual-build-cjs-loads` first answered exit 3 `PREREQUISITE NOT MET`, which measures nothing. Rerun with their named build prerequisites, both exit 0. - `--ran` reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, derived from 68 recorded exit codes. - **Lint, narrowed and proven.** eslint's own `--print-config` resolves all three changed code files to a config, so none is ignored. `eslint --no-inline-config --format json` over them: 3 files, 0 errors, 0 warnings. Invariance: no `parserOptions.project` is set and the 5 active rules are per-file AST rules, so this diff cannot change the verdict on any untouched file. The whole-repo `pnpm lint` is CI's. - **Not measured locally (CI's).** The path-scheduled CI jobs and the type-check lanes that the derivation lists outside its commands. ## Acceptance notes - **Out-of-scope finding (same class, not fixed here, decision needed): the `email` audience.** A live link with a password and audience `email` answers an anonymous caller who presents the correct password with no email, or an email off the allowlist, `401 WRONG_PASSWORD` on both mounts (measured in-process, both mounts, on the same real-SqlDriver harness). It is the same password-before-audience shape. It is not a mechanical fix: there is no email-audience refusal code, and answering the allowlist arm before the password arm would hand an anonymous token holder an allowlist-membership oracle. The console's share dialog offers no `email` audience today. Handed to the seat to file. - **Out-of-scope finding (mount divergence): the plugin mount's conversation-messages door ignores the caller.** It calls `resolveToken` without the caller's identity (its `serve` ignores the context the family door resolved), while the dispatcher twin passes it. A signed-in caller of a `signed_in` conversation link reads `404 NOT_FOUND` from the plugin mount and `200` from the twin (measured, both mounts). The console offers the `signed_in` audience and fetches the conversation's messages. Handed to the seat to file. - **Single source, later.** If the seat wants the order to live in one place without a new dependency edge, `@objectstack/types` already hosts the password-header reading both mounts share for exactly this reason (`readSharePasswordHeader`). That would be a follow-up outside this card's file surface. - **Cross-lane.** `packages/runtime` is declared to `domain:cli` (comment 6158867992 on #22648) and `plugin-sharing` to #15194 (comment 6158874477). --- _Generated by [Claude Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 713f231 commit 28e294f

4 files changed

Lines changed: 302 additions & 6 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 that has a password and is limited to signed-in users answers an anonymous visitor `401 SIGN_IN_REQUIRED`, whatever password was sent
7+
8+
Clause-②: no — every request refused before is still refused; only which refusal code an anonymous caller reads changes.
9+
10+
A share link can carry a password and the `signed_in` audience together. For such a link, the share-link resolve door answered an anonymous visitor from the password before the audience: `NEEDS_PASSWORD` with no password, and `WRONG_PASSWORD` with any password, the correct one included. The console's shared-record page then told a visitor who typed the right password "Wrong password.", and never offered sign-in.
11+
12+
- **Anonymous caller, `signed_in` link:** `401 SIGN_IN_REQUIRED` with no password, a wrong one or the correct one. The answer is the same for all three, so it never tells an anonymous caller whether a password is right. Once signed in, the visitor is asked for the password as before.
13+
- **Signed-in caller:** unchanged. `401 NEEDS_PASSWORD` with no password, `401 WRONG_PASSWORD` with a wrong one, and the record with the correct one.
14+
- **Unchanged:** links with any other audience (a `public` link with a password still answers `NEEDS_PASSWORD` / `WRONG_PASSWORD` to an anonymous caller), the expired-or-revoked `410 EXPIRED_OR_REVOKED`, the `404 INVALID_OR_EXPIRED` for an unknown token or an object with public sharing switched off, and every link that resolves.
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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,22 @@ export function registerShareLinkRoutes(
447447
return invalidOrExpired();
448448
}
449449
if (row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now())) {
450+
// [#23328] The audience is judged BEFORE the password. Before sign-in
451+
// the probe asks the audience and never the password: `resolveToken`
452+
// refuses an anonymous caller of a `signed_in` link on the audience
453+
// before it verifies any password, so the password arm below knows
454+
// only whether a password was SENT — answered first, it told the
455+
// holder of the CORRECT password `WRONG_PASSWORD`, and the console
456+
// never offered sign-in. Asked first, an anonymous caller reads one
457+
// answer whatever it sent, so nothing here can tell it whether a
458+
// guessed password is right. A signed-in caller keeps the password
459+
// arms. The dispatcher twin (`runtime/src/domains/share-links.ts`)
460+
// carries the same order — it cannot import this package, which is
461+
// a dev dependency there — and one table holds both mounts to it:
462+
// `runtime/src/domains/share-links-refusal-order.test.ts`.
463+
if (row.audience === 'signed_in' && !signedInUserId) {
464+
return sendError(res, 401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
465+
}
450466
// [#21197] `password_hash` is `internal`, so the probe row comes back
451467
// without it; read off the row, every protected link would answer
452468
// "invalid" here instead of prompting for its password. Recovered
@@ -460,9 +476,6 @@ export function registerShareLinkRoutes(
460476
providedPassword ? 'Incorrect password' : 'This link requires a password',
461477
);
462478
}
463-
if (row.audience === 'signed_in' && !signedInUserId) {
464-
return sendError(res, 401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
465-
}
466479
}
467480
if (row && (row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()))) {
468481
return sendError(res, 410, 'EXPIRED_OR_REVOKED', 'Share link has expired or been revoked');
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23328] The share-link resolve refusal order: ONE table, BOTH mounts.
5+
*
6+
* `/share-links/:token/resolve` has two mounts: plugin-sharing's
7+
* `registerShareLinkRoutes` (the standalone server's) and this package's
8+
* dispatcher twin `handleShareLinksRequest` (cloud's per-environment kernels,
9+
* `registerShareLinkRoutes: false`). When `resolveToken` refuses, each mount
10+
* probes the row to name the refusal, and each carries its own copy of that
11+
* probe's arm order — `@objectstack/plugin-sharing` is a DEV dependency of
12+
* this package, so the twin cannot import the plugin's code. This table is
13+
* what holds the two copies in step.
14+
*
15+
* ## The rule it pins
16+
*
17+
* For a live link whose audience is `signed_in`, an anonymous caller is
18+
* answered `401 SIGN_IN_REQUIRED` WHATEVER password it sent. Before sign-in
19+
* the probe judges the audience, never the password: `resolveToken` refuses
20+
* that caller on the audience before it verifies any password, so a probe
21+
* that answered a password arm first could only report whether a password
22+
* was SENT — and it answered `WRONG_PASSWORD` for the correct one.
23+
*
24+
* The oracle property is pinned on its own: an anonymous caller reads the
25+
* SAME answer, byte for byte, for no password, a wrong one and the correct
26+
* one. Any difference there, before sign-in, would tell an anonymous holder
27+
* of the token whether a guessed password is right.
28+
*
29+
* A signed-in caller keeps the password arms: `NEEDS_PASSWORD`,
30+
* `WRONG_PASSWORD`, and the record for the correct password. The controls
31+
* are unchanged by the rule: a `public` link with a password still answers
32+
* the password arms to an anonymous caller, an unprotected `public` link
33+
* still serves, and a revoked link still answers the 410 whoever asks.
34+
*
35+
* ## What is real here
36+
*
37+
* Both mounts' production entries over ONE `ObjectQL` on
38+
* `@objectstack/driver-sql` + better-sqlite3 with the shipped
39+
* `sys_share_link` definition (whose `password_hash` is `internal`, so both
40+
* probes recover it the fail-closed way), ONE `ShareLinkService` with the
41+
* production password hasher, and the password sent the way the console's
42+
* landing page sends it: the `X-Share-Password` header. Refusals are read as
43+
* the ADR-0112 envelope's `status` + `error.code`.
44+
*/
45+
46+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
47+
import { ObjectQL } from '@objectstack/objectql';
48+
import { SqlDriver } from '@objectstack/driver-sql';
49+
import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts';
50+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
51+
import type { ExecutionContext } from '@objectstack/spec/kernel';
52+
import { ShareLinkService, SysShareLink, registerShareLinkRoutes } from '@objectstack/plugin-sharing';
53+
import { apiErrorResponse } from '../error-envelope.js';
54+
import { HttpDispatcher } from '../http-dispatcher.js';
55+
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
56+
import type { HttpProtocolContext } from '../http-dispatcher.js';
57+
import { handleShareLinksRequest } from './share-links.js';
58+
59+
const OBJECT = 'refusal_doc';
60+
const RECORD = 'doc_1';
61+
const VIEWER = 'usr_viewer';
62+
const PASSWORD = 'correct horse 23328';
63+
const WRONG = 'not the password 23328';
64+
65+
const TARGET = {
66+
name: OBJECT,
67+
label: 'Refusal Doc',
68+
publicSharing: { enabled: true, allowedAudiences: ['public', 'signed_in'], allowedPermissions: ['view'] },
69+
fields: {
70+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
71+
title: { name: 'title', label: 'Title', type: 'text' },
72+
},
73+
};
74+
75+
type Mount = 'plugin route' | 'dispatcher twin';
76+
type Caller = 'anonymous' | 'signed in';
77+
type Presented = 'no password' | 'a wrong password' | 'the correct password';
78+
type LinkKey = 'signed_in + password' | 'public + password' | 'public' | 'revoked signed_in + password';
79+
type Answer = { status: number; body: any; headers: Record<string, string> };
80+
81+
const MOUNTS: readonly Mount[] = ['plugin route', 'dispatcher twin'];
82+
const PRESENTED: Record<Presented, string | undefined> = {
83+
'no password': undefined,
84+
'a wrong password': WRONG,
85+
'the correct password': PASSWORD,
86+
};
87+
88+
/** The dispatcher's REAL thrown-error mapper, as the sibling suites borrow it. */
89+
const realErrorFromThrown = (() => {
90+
const dispatcher: any = new HttpDispatcher({ context: { getService: () => null } } as any);
91+
return (e: any, fallbackStatus?: number) => dispatcher.errorFromThrown(e, fallbackStatus);
92+
})();
93+
94+
function makeDeps(engine: any, svc: any): DomainHandlerDeps {
95+
const deps: any = {
96+
resolveService: async (_c: any, name: string) =>
97+
name === SHARE_LINK_SERVICE ? svc : name === 'objectql' ? engine : undefined,
98+
getRequestKernelService: async (_c: any, name: string) => (name === 'objectql' ? engine : undefined),
99+
success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, ...(meta ? { meta } : {}) } }),
100+
error: (message: string, httpStatus = 500, details?: any) => apiErrorResponse({ message, httpStatus, details }),
101+
routeNotFound: (route: string) => apiErrorResponse({ message: `Route not found: ${route}`, httpStatus: 404 }),
102+
errorFromThrown: realErrorFromThrown,
103+
};
104+
return deps as DomainHandlerDeps;
105+
}
106+
107+
/** The smallest `IHttpServer` that keeps the handlers a registrar mounts. */
108+
class RouteRecorder implements IHttpServer {
109+
readonly routes = new Map<string, RouteHandler>();
110+
get(path: string, handler: RouteHandler) { this.routes.set(`GET ${path}`, handler); }
111+
post(path: string, handler: RouteHandler) { this.routes.set(`POST ${path}`, handler); }
112+
put(path: string, handler: RouteHandler) { this.routes.set(`PUT ${path}`, handler); }
113+
delete(path: string, handler: RouteHandler) { this.routes.set(`DELETE ${path}`, handler); }
114+
patch(path: string, handler: RouteHandler) { this.routes.set(`PATCH ${path}`, handler); }
115+
use() { /* no middleware is mounted by the registrar under test */ }
116+
async listen() { /* never listens: handlers are driven in-process */ }
117+
}
118+
119+
const CALLERS: Record<Caller, ExecutionContext> = {
120+
anonymous: {},
121+
'signed in': { userId: VIEWER },
122+
};
123+
124+
let engine: ObjectQL;
125+
const tokens = {} as Record<LinkKey, string>;
126+
let open: (mount: Mount, link: LinkKey, caller: Caller, presented: Presented) => Promise<Answer>;
127+
128+
beforeAll(async () => {
129+
engine = new ObjectQL();
130+
engine.registerDriver(
131+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any,
132+
true,
133+
);
134+
await engine.init();
135+
for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/runtime-test');
136+
await engine.syncSchemas();
137+
await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any);
138+
139+
const svc = new ShareLinkService({ engine: engine as any });
140+
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);
143+
tokens['signed_in + password'] = (await mint('signed_in', PASSWORD)).token;
144+
tokens['public + password'] = (await mint('public', PASSWORD)).token;
145+
tokens.public = (await mint('public')).token;
146+
const revoked = await mint('signed_in', PASSWORD);
147+
await svc.revokeLink(revoked.id, creator);
148+
tokens['revoked signed_in + password'] = revoked.token;
149+
150+
// The plugin mount, once per caller: the family door resolves the caller
151+
// through `contextFromRequest`, exactly as production wires it.
152+
const pluginRoutes = {} as Record<Caller, RouteHandler>;
153+
for (const caller of Object.keys(CALLERS) as Caller[]) {
154+
const http = new RouteRecorder();
155+
registerShareLinkRoutes(http, svc, engine as any, { contextFromRequest: () => CALLERS[caller] });
156+
const handler = http.routes.get('GET /api/v1/share-links/:token/resolve');
157+
if (!handler) throw new Error('registerShareLinkRoutes mounted no resolve route');
158+
pluginRoutes[caller] = handler;
159+
}
160+
const deps = makeDeps(engine, svc);
161+
162+
open = async (mount, link, caller, presented) => {
163+
const token = tokens[link];
164+
const password = PRESENTED[presented];
165+
const headers: Record<string, string> = password === undefined ? {} : { 'x-share-password': password };
166+
if (mount === 'dispatcher twin') {
167+
const res = await handleShareLinksRequest(deps, `/${token}/resolve`, 'GET', undefined, {}, {
168+
executionContext: CALLERS[caller],
169+
request: { headers },
170+
} as unknown as HttpProtocolContext);
171+
if (!res.handled || !res.response) throw new Error('the dispatcher twin did not handle resolve');
172+
const r = res.response as { status: number; body: any; headers?: Record<string, string> };
173+
return { status: r.status, body: r.body, headers: { ...(r.headers ?? {}) } };
174+
}
175+
const captured: Answer = { status: 200, body: undefined, headers: {} };
176+
const res: IHttpResponse = {
177+
json: (data: any) => { captured.body = data; },
178+
send: () => { /* the share-link routes answer JSON only */ },
179+
status: (code: number) => { captured.status = code; return res; },
180+
header: (name: string, value: string) => { captured.headers[name] = value; return res; },
181+
};
182+
const req: IHttpRequest = {
183+
params: { token },
184+
query: {},
185+
body: undefined,
186+
headers,
187+
method: 'GET',
188+
path: `/api/v1/share-links/${token}/resolve`,
189+
};
190+
await pluginRoutes[caller](req, res);
191+
return captured;
192+
};
193+
}, 60_000);
194+
195+
afterAll(async () => {
196+
try {
197+
await engine?.destroy();
198+
} catch {
199+
/* noop */
200+
}
201+
});
202+
203+
interface Row {
204+
link: LinkKey;
205+
caller: Caller;
206+
presented: Presented;
207+
status: number;
208+
/** The refusal's `error.code`; absent on a row that serves the record. */
209+
code?: string;
210+
}
211+
212+
/** The card's table, then the controls. */
213+
const TABLE: readonly Row[] = [
214+
// A password AND audience `signed_in`: before sign-in, the audience alone.
215+
{ link: 'signed_in + password', caller: 'anonymous', presented: 'no password', status: 401, code: 'SIGN_IN_REQUIRED' },
216+
{ link: 'signed_in + password', caller: 'anonymous', presented: 'a wrong password', status: 401, code: 'SIGN_IN_REQUIRED' },
217+
{ link: 'signed_in + password', caller: 'anonymous', presented: 'the correct password', status: 401, code: 'SIGN_IN_REQUIRED' },
218+
// Signed in, the password arms.
219+
{ link: 'signed_in + password', caller: 'signed in', presented: 'no password', status: 401, code: 'NEEDS_PASSWORD' },
220+
{ link: 'signed_in + password', caller: 'signed in', presented: 'a wrong password', status: 401, code: 'WRONG_PASSWORD' },
221+
{ link: 'signed_in + password', caller: 'signed in', presented: 'the correct password', status: 200 },
222+
// Control: a `public` link with a password keeps the password arms for an anonymous caller.
223+
{ link: 'public + password', caller: 'anonymous', presented: 'no password', status: 401, code: 'NEEDS_PASSWORD' },
224+
{ link: 'public + password', caller: 'anonymous', presented: 'a wrong password', status: 401, code: 'WRONG_PASSWORD' },
225+
{ link: 'public + password', caller: 'anonymous', presented: 'the correct password', status: 200 },
226+
// Control: an unprotected `public` link serves an anonymous caller.
227+
{ link: 'public', caller: 'anonymous', presented: 'no password', status: 200 },
228+
// Control: the 410 arm is not reordered — a revoked link answers it whoever asks.
229+
{ link: 'revoked signed_in + password', caller: 'anonymous', presented: 'the correct password', status: 410, code: 'EXPIRED_OR_REVOKED' },
230+
{ link: 'revoked signed_in + password', caller: 'signed in', presented: 'the correct password', status: 410, code: 'EXPIRED_OR_REVOKED' },
231+
];
232+
233+
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);
236+
const label = `${mount}: ${JSON.stringify(answer.body)}`;
237+
expect(answer.status, label).toBe(row.status);
238+
if (row.code) {
239+
expect(answer.body?.success, label).toBe(false);
240+
expect(answer.body?.error?.code, label).toBe(row.code);
241+
} else {
242+
expect(answer.body?.success, label).toBe(true);
243+
expect(answer.body?.data?.record?.id, label).toBe(RECORD);
244+
}
245+
});
246+
247+
it('no anonymous password oracle: no, a wrong and the correct password read one identical answer', async () => {
248+
const none = await open(mount, 'signed_in + password', 'anonymous', 'no password');
249+
const wrong = await open(mount, 'signed_in + password', 'anonymous', 'a wrong password');
250+
const correct = await open(mount, 'signed_in + password', 'anonymous', 'the correct password');
251+
expect(correct, 'the correct password against a wrong one').toEqual(wrong);
252+
expect(correct, 'the correct password against none').toEqual(none);
253+
});
254+
});

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,23 @@ async function handleShareLinksRequestBody(
306306
if (!isPublicSharingEnabled(schema)) return invalidOrExpired();
307307
}
308308
const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now());
309+
// [#23328] The audience is judged BEFORE the password. Before
310+
// sign-in this probe asks the audience and never the password:
311+
// `resolveToken` refuses an anonymous caller of a `signed_in`
312+
// link on the audience before it verifies any password, so the
313+
// password arm below knows only whether a password was SENT —
314+
// answered first, it told the holder of the CORRECT password
315+
// `WRONG_PASSWORD`, and the console never offered sign-in.
316+
// Asked first, an anonymous caller reads one answer whatever it
317+
// sent, so nothing here can tell it whether a guessed password
318+
// is right. A signed-in caller keeps the password arms. The
319+
// plugin-sharing twin carries the same order; this copy cannot
320+
// import it (`@objectstack/plugin-sharing` is a DEV dependency
321+
// here), so one table holds both mounts to it:
322+
// `share-links-refusal-order.test.ts` beside this file.
323+
if (live && row.audience === 'signed_in' && !signedInUserId) {
324+
return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
325+
}
309326
// [#21197] `sys_share_link.password_hash` is `internal: true`, so the
310327
// probe row above comes back WITHOUT it; read off the row, every
311328
// protected link would answer the unknown-token shape and the
@@ -321,9 +338,6 @@ async function handleShareLinksRequestBody(
321338
return sendErr(401, providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD',
322339
providedPassword ? 'Incorrect password' : 'This link requires a password');
323340
}
324-
if (live && row.audience === 'signed_in' && !signedInUserId) {
325-
return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
326-
}
327341
if (row && (row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()))) {
328342
return sendErr(410, 'EXPIRED_OR_REVOKED', 'Share link has expired or been revoked');
329343
}

0 commit comments

Comments
 (0)