Skip to content

Commit c3791ea

Browse files
committed
fix(plugin-sharing): share-link public answers are not cached, the password header passes CORS, and hashing works in WebContainer
- X-Share-Password joins DEFAULT_CORS_ALLOW_HEADERS so a cross-origin client can use the header form. - Both public share-link routes answer Cache-Control: no-store and Vary: X-Share-Password on every outcome, on both mounts. - On WebContainer the password key is derived by @noble/hashes scrypt with the same parameters and stored form as node:crypto; hashes interchange. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
1 parent 4f7ac62 commit c3791ea

12 files changed

Lines changed: 410 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
---
22
'@objectstack/plugin-sharing': patch
3+
'@objectstack/plugin-hono-server': patch
4+
'@objectstack/runtime': patch
35
---
46

57
Share-link passwords follow the platform's credential rules (#21839).
68

79
- **The stored hash never leaves the server.** The share-link mint response (`POST /api/v1/share-links`, and `ShareLinkService.createLink`'s return value) no longer carries `password_hash`. The list and the redemption result are projected the same way. A client that reads a link's password state keeps reading it from the redemption route's `NEEDS_PASSWORD` answer, as before.
810
- **The stored form is the platform's slow password hash.** New passwords are hashed with scrypt at the parameters account passwords use, instead of one salted SHA-256. Links minted before this release keep working: a stored password in a legacy form still verifies, and it is re-hashed into the new form on its first successful redemption. Every comparison is constant-time. A deployment that injects its own `hashPassword` / `verifyPassword` pair is unaffected, and its stored forms are left alone.
911
- **The password travels in a header.** Both public share-link routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) accept the `x-share-password` request header, the preferred form, because a header is not part of the request URL. The `?password=` query parameter is still accepted for compatibility, so current consoles keep working until they move to the header. `/messages` accepted only the query parameter on this mount before.
12+
- **Cross-origin clients can send the header.** `X-Share-Password` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` is unchanged; add the header to that list to let a cross-origin client use it.
13+
- **Public share-link answers are not cached.** Both public routes answer with `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, on both mounts (the sharing plugin's routes and the runtime dispatcher's `/share-links` domain). The authenticated create, list and revoke routes are unchanged.
14+
- **Hashing works in WebContainer.** On StackBlitz WebContainer, where `node:crypto.scrypt` is incomplete, the password is hashed with the pure-JS scrypt from `@noble/hashes` (now a dependency of `@objectstack/plugin-sharing`, as it already is of `@objectstack/plugin-auth`), at the same parameters and in the same stored form. A hash made on either runtime verifies on the other.

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,18 +999,19 @@ ObjectStack sends CORS headers automatically:
999999
```http
10001000
Access-Control-Allow-Origin: https://app.acme.com
10011001
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
1002-
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match
1002+
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, X-Share-Password
10031003
Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields
10041004
Access-Control-Max-Age: 86400
10051005
```
10061006

1007-
Three of the allowed request headers are easy to overlook, and each one disables
1007+
Four of the allowed request headers are easy to overlook, and each one disables
10081008
a feature if an intermediate proxy strips it:
10091009

10101010
| Header | Why it is allowed |
10111011
| --- | --- |
10121012
| `X-Tenant-ID` / `X-Environment-Id` | Route the request to its environment on a multi-tenant host. |
10131013
| `If-Match` | Carries the OCC token on record `PATCH`es. Without it, a cross-origin save fails in the browser with "Failed to fetch". |
1014+
| `X-Share-Password` | Carries a share-link password to the public `/share-links/:token/resolve` and `/messages` routes. It is the preferred form because a header stays out of URLs; without it, a cross-origin client can only send the password as a query parameter. |
10141015

10151016
The two **exposed** response headers matter to browser clients specifically:
10161017
`set-auth-token` delivers a rotated session token (without it a cross-origin

‎packages/plugins/plugin-hono-server/src/adapter.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ import {
6464
* `If-Match` carries the OCC token on record PATCHes (objectui's inline edit,
6565
* REST `update` with `ifMatch`) — without it in the preflight allow-list every
6666
* cross-origin save fails in the browser with "Failed to fetch" (objectui#2572).
67+
* `X-Share-Password` carries a share-link password to the public
68+
* `/share-links/:token/resolve` and `/messages` routes — the preferred form,
69+
* since a header stays out of URLs (#21839); without it here a cross-origin
70+
* client could only use the query-parameter form.
6771
*/
6872
export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
6973
'Content-Type',
@@ -72,6 +76,7 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
7276
'X-Tenant-ID',
7377
'X-Environment-Id',
7478
'If-Match',
79+
'X-Share-Password',
7580
]);
7681

7782
/**

‎packages/plugins/plugin-hono-server/src/hono-plugin.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,17 @@ describe('HonoServerPlugin', () => {
313313
expect(corsConfigCapture.last.allowHeaders).toContain('If-Match');
314314
});
315315

316+
it('should allow X-Share-Password by default (share-link password header, #21839)', async () => {
317+
corsConfigCapture.last = undefined;
318+
319+
const plugin = new HonoServerPlugin();
320+
await plugin.init(context as PluginContext);
321+
322+
// The header form keeps the password out of URLs; a preflight that
323+
// does not allow it leaves a cross-origin client only the query form.
324+
expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password');
325+
});
326+
316327
it('should merge user-supplied exposeHeaders with set-auth-token default', async () => {
317328
corsConfigCapture.last = undefined;
318329

‎packages/plugins/plugin-sharing/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-sharing --project tsconfig.test.json"
2121
},
2222
"dependencies": {
23+
"@noble/hashes": "^2.4.0",
2324
"@objectstack/core": "workspace:*",
2425
"@objectstack/formula": "workspace:*",
2526
"@objectstack/metadata-core": "workspace:*",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21839] On WebContainer the share-link password is hashed by the pure-JS
5+
* scrypt (`@noble/hashes`), because that host's `node:crypto.scrypt` is
6+
* incomplete; everywhere else by `node:crypto`. The two must be one hash.
7+
*
8+
* Pinned:
9+
* - with a WebContainer signal set, `node:crypto.scrypt` is never called —
10+
* the pure-JS path really ran;
11+
* - without one, it is — the native path really ran;
12+
* - a hash minted on either path verifies on the other, and a wrong password
13+
* is refused on both;
14+
* - the two paths derive byte-identical keys for the same password and salt
15+
* (compared directly against `node:crypto`).
16+
*/
17+
18+
import { describe, it, expect, vi, afterEach } from 'vitest';
19+
import * as nodeCrypto from 'node:crypto';
20+
import { hashShareLinkPassword, verifyShareLinkPassword } from './share-link-password.js';
21+
22+
vi.mock('node:crypto', async (importOriginal) => {
23+
const actual = await importOriginal<typeof import('node:crypto')>();
24+
return { ...actual, scrypt: vi.fn(actual.scrypt) };
25+
});
26+
27+
const PASSWORD = 'Ünïcödé pass 21839';
28+
const nodeScrypt = vi.mocked(nodeCrypto.scrypt);
29+
30+
function onWebContainer(on: boolean) {
31+
vi.stubEnv('STACKBLITZ', on ? '1' : '');
32+
vi.stubEnv('SHELL', on ? '/bin/jsh' : '/bin/bash');
33+
}
34+
35+
afterEach(() => {
36+
vi.unstubAllEnvs();
37+
nodeScrypt.mockClear();
38+
});
39+
40+
describe('[#21839] share-link password: node:crypto and pure-JS scrypt are interchangeable', () => {
41+
it('the WebContainer path does not touch node:crypto.scrypt; the native path does', async () => {
42+
onWebContainer(true);
43+
await hashShareLinkPassword(PASSWORD);
44+
expect(nodeScrypt).not.toHaveBeenCalled();
45+
46+
onWebContainer(false);
47+
await hashShareLinkPassword(PASSWORD);
48+
expect(nodeScrypt).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it('a hash minted on WebContainer verifies natively, and the reverse', async () => {
52+
onWebContainer(true);
53+
const pureJsHash = await hashShareLinkPassword(PASSWORD);
54+
onWebContainer(false);
55+
const nativeHash = await hashShareLinkPassword(PASSWORD);
56+
57+
expect(pureJsHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
58+
expect(nativeHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
59+
60+
onWebContainer(false);
61+
expect(await verifyShareLinkPassword(PASSWORD, pureJsHash)).toBe(true);
62+
expect(await verifyShareLinkPassword('wrong 21839', pureJsHash)).toBe(false);
63+
expect(nodeScrypt).toHaveBeenCalled();
64+
65+
onWebContainer(true);
66+
nodeScrypt.mockClear();
67+
expect(await verifyShareLinkPassword(PASSWORD, nativeHash)).toBe(true);
68+
expect(await verifyShareLinkPassword('wrong 21839', nativeHash)).toBe(false);
69+
expect(nodeScrypt).not.toHaveBeenCalled();
70+
});
71+
72+
it('the pure-JS key is byte-identical to node:crypto for the same password and salt', async () => {
73+
onWebContainer(true);
74+
const hash = await hashShareLinkPassword(PASSWORD);
75+
const [, saltHex, keyHex] = hash.split('$');
76+
const expected = nodeCrypto
77+
.scryptSync(PASSWORD.normalize('NFKC'), saltHex!, 64, { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 })
78+
.toString('hex');
79+
expect(keyHex).toBe(expected);
80+
});
81+
});

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

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
* - the `x-share-password` header is accepted on both public routes, the
2323
* `?password=` query parameter still is, and a wrong password is refused
2424
* through either form;
25-
* - no log line carries the presented password.
25+
* - no log line carries the presented password;
26+
* - both public routes answer `Cache-Control: no-store` and
27+
* `Vary: X-Share-Password` on every outcome, and the authenticated routes
28+
* do not;
29+
* - the pure-JS scrypt the WebContainer path uses and `node:crypto`'s produce
30+
* interchangeable hashes.
2631
*/
2732

2833
import { describe, it, expect, afterEach, vi } from 'vitest';
@@ -98,15 +103,15 @@ async function drive(
98103
headers?: Record<string, string>;
99104
body?: unknown;
100105
} = {},
101-
): Promise<{ status: number; body: any }> {
106+
): Promise<{ status: number; body: any; headers: Record<string, string | string[]> }> {
102107
const handler = http.routes.get(key);
103108
if (!handler) throw new Error(`no handler for ${key}`);
104-
const captured = { status: 200, body: undefined as any };
109+
const captured = { status: 200, body: undefined as any, headers: {} as Record<string, string | string[]> };
105110
const res: IHttpResponse = {
106111
json: vi.fn((data: any) => { captured.body = data; }) as any,
107112
send: vi.fn() as any,
108113
status: vi.fn((code: number) => { captured.status = code; return res; }) as any,
109-
header: vi.fn(() => res) as any,
114+
header: vi.fn((name: string, value: string | string[]) => { captured.headers[name] = value; return res; }) as any,
110115
};
111116
const req: IHttpRequest = {
112117
params: opts.params ?? {},
@@ -472,3 +477,41 @@ describe('[#21839] how the password travels in', () => {
472477
expect(loggedText(logger)).not.toContain('wrong one 21839');
473478
});
474479
});
480+
481+
describe('[#21839] the public routes are never cached', () => {
482+
function expectNoStore(res: { headers: Record<string, string | string[]> }, label: string) {
483+
expect(res.headers['Cache-Control'], label).toBe('no-store');
484+
expect(res.headers.Vary, label).toBe('X-Share-Password');
485+
}
486+
487+
it.each(['resolve', 'messages'] as const)('/%s sends no-store + Vary on success and on every refusal', async (route) => {
488+
const booted = await boot();
489+
const link = await booted.service.createLink(
490+
{ object: 'ai_conversations', recordId: 'conv_1', password: PASSWORD },
491+
CREATOR,
492+
);
493+
const key = `GET ${B}/:token/${route}`;
494+
const ok = await drive(booted.http, key, { params: { token: link.token }, headers: { 'x-share-password': PASSWORD } });
495+
expect(ok.status).toBe(200);
496+
expectNoStore(ok, 'success');
497+
498+
const bare = await drive(booted.http, key, { params: { token: link.token } });
499+
expect(bare.status).not.toBe(200);
500+
expectNoStore(bare, 'no password');
501+
502+
const wrong = await drive(booted.http, key, { params: { token: link.token }, query: { password: 'nope 21839' } });
503+
expect(wrong.status).not.toBe(200);
504+
expectNoStore(wrong, 'wrong password');
505+
506+
const unknown = await drive(booted.http, key, { params: { token: 'no-such-token-21839' } });
507+
expect(unknown.status).toBe(404);
508+
expectNoStore(unknown, 'unknown token');
509+
});
510+
511+
it('the authenticated list route is not given the public headers', async () => {
512+
const { http } = await boot();
513+
const res = await drive(http, `GET ${B}`);
514+
expect(res.headers['Cache-Control']).toBeUndefined();
515+
expect(res.headers.Vary).toBeUndefined();
516+
});
517+
});

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,19 @@
1111
* carries for WebContainer): N=16384, r=16, p=1, a 64-byte key, a 16-byte
1212
* random salt passed as its hex string, and the password NFKC-normalised.
1313
* Same algorithm and parameters, so a share-link password is exactly as
14-
* expensive to brute-force from a database dump as a sign-in password. The
15-
* implementation is `node:crypto`'s scrypt — no new dependency.
14+
* expensive to brute-force from a database dump as a sign-in password.
15+
*
16+
* ## Two implementations, one hash
17+
*
18+
* On Node the key is derived by `node:crypto`'s scrypt. WebContainer
19+
* (StackBlitz) reports itself as Node but polyfills `node:crypto.scrypt`
20+
* incompletely, so there — detected exactly as `plugin-auth`'s
21+
* `isWebContainerRuntime()` detects it — the key is derived by
22+
* `@noble/hashes/scrypt` instead, the same pure-JS scrypt `plugin-auth` swaps
23+
* in for account passwords on that host. Same parameters, same salt input,
24+
* same output bytes: a hash minted by either verifies under the other, so a
25+
* link created in a WebContainer keeps working when the app is deployed. The
26+
* pure-JS module is loaded only on that host; elsewhere it is never imported.
1627
*
1728
* ## The legacy forms, and why they are upgraded on read rather than migrated
1829
*
@@ -47,7 +58,38 @@ const CURRENT_PREFIX = 'scrypt$';
4758
const LEGACY_SHA256_PREFIX = 'sha256$';
4859
const LEGACY_PLAINTEXT_PREFIX = 'weak$';
4960

61+
/**
62+
* WebContainer (StackBlitz) detection — the same three signals `plugin-auth`'s
63+
* `isWebContainerRuntime()` and `service-settings`' local crypto provider read.
64+
*/
65+
function isWebContainerRuntime(): boolean {
66+
const proc = (globalThis as { process?: { versions?: Record<string, unknown>; env?: Record<string, unknown> } })
67+
.process;
68+
return (
69+
Boolean(proc?.versions?.webcontainer) ||
70+
(typeof proc?.env?.SHELL === 'string' && proc.env.SHELL.includes('jsh')) ||
71+
Boolean(proc?.env?.STACKBLITZ)
72+
);
73+
}
74+
75+
/** The pure-JS scrypt, with exactly the parameters {@link deriveKeyNode} passes. */
76+
async function deriveKeyPureJs(password: string, saltHex: string): Promise<Buffer> {
77+
const { scryptAsync } = await import('@noble/hashes/scrypt.js');
78+
const key = await scryptAsync(password.normalize('NFKC'), saltHex, {
79+
N: SCRYPT_N,
80+
r: SCRYPT_R,
81+
p: SCRYPT_P,
82+
dkLen: SCRYPT_KEY_BYTES,
83+
maxmem: SCRYPT_MAXMEM,
84+
});
85+
return Buffer.from(key);
86+
}
87+
5088
function deriveKey(password: string, saltHex: string): Promise<Buffer> {
89+
return isWebContainerRuntime() ? deriveKeyPureJs(password, saltHex) : deriveKeyNode(password, saltHex);
90+
}
91+
92+
function deriveKeyNode(password: string, saltHex: string): Promise<Buffer> {
5193
return new Promise((resolve, reject) => {
5294
scrypt(
5395
password.normalize('NFKC'),

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
* and renders the response read-only.
3131
*/
3232

33-
import type { IHttpServer, IHttpRequest, RouteHandler } from '@objectstack/spec/contracts';
33+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
3434
// The declared envelope is written in ONE place for the whole platform (#3973).
3535
import { sendOk, sendError } from '@objectstack/types';
3636
import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts';
@@ -149,6 +149,27 @@ function presentedPassword(req: IHttpRequest): string | undefined {
149149
return typeof header === 'string' ? header : undefined;
150150
}
151151

152+
/**
153+
* [#21839] Response headers both public routes (`/resolve` and `/messages`)
154+
* answer with, on every outcome.
155+
*
156+
* `Cache-Control: no-store` — the body is a record released by a capability
157+
* token (and, for a protected link, by a password); no browser or shared cache
158+
* may keep a copy of it, nor of a refusal that would be replayed after the
159+
* link changes. `Vary: X-Share-Password` — the answer depends on that request
160+
* header, so any cache that does not honour `no-store` must at least never
161+
* serve one presenter's answer to another. The dispatcher twin
162+
* (`runtime/src/domains/share-links.ts`) sends the same pair.
163+
*/
164+
const SHARE_LINK_PUBLIC_RESPONSE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
165+
'Cache-Control': 'no-store',
166+
Vary: 'X-Share-Password',
167+
});
168+
169+
function setPublicResponseHeaders(res: IHttpResponse): void {
170+
for (const [name, value] of Object.entries(SHARE_LINK_PUBLIC_RESPONSE_HEADERS)) res.header(name, value);
171+
}
172+
152173
/** Strip `redactFields` from a record (also removes from nested arrays of objects). */
153174
function applyRedaction(record: any, redactFields: string[]): any {
154175
if (!record || typeof record !== 'object' || redactFields.length === 0) return record;
@@ -248,6 +269,7 @@ export function registerShareLinkRoutes(
248269
// No `ctxOf` here — the token IS the authorisation. We still allow
249270
// probes from a signed-in user so audience=signed_in is satisfiable.
250271
http.get(`${base}/:token/resolve`, (async (req, res) => {
272+
setPublicResponseHeaders(res);
251273
try {
252274
const q = req.query ?? {};
253275
// [Finding-2] The `audience: 'signed_in'` gate must key off the VERIFIED
@@ -381,6 +403,7 @@ export function registerShareLinkRoutes(
381403
// following the same pattern.
382404
// ──────────────────────────────────────────────────────────────
383405
http.get(`${base}/:token/messages`, (async (req, res) => {
406+
setPublicResponseHeaders(res);
384407
try {
385408
const resolved = await service.resolveToken(req.params.token, {
386409
providedPassword: presentedPassword(req),

0 commit comments

Comments
 (0)