Skip to content

Commit bb53601

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21886-add-member-visibility
2 parents 57b18bf + 5cfd866 commit bb53601

13 files changed

Lines changed: 747 additions & 34 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/plugin-sharing": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/plugin-hono-server": minor
6+
"@objectstack/hono": minor
7+
---
8+
9+
feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`)
10+
11+
Clause-②: yes (widening)
12+
13+
- **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header.
14+
- **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants.
15+
- **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read.
16+
- **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value.
17+
- **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`.
18+
- **Cross-origin clients.** `X-Share-Password-Encoding` 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` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,19 +999,20 @@ 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, X-Share-Password
1002+
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, X-Share-Password, X-Share-Password-Encoding
10031003
Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields
10041004
Access-Control-Max-Age: 86400
10051005
```
10061006

1007-
Four of the allowed request headers are easy to overlook, and each one disables
1007+
Five 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". |
10141014
| `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. |
1015+
| `X-Share-Password-Encoding` | Declares how `X-Share-Password` is encoded. Its one accepted value is `utf-8` (any case): the password header then carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them. A browser cannot put a character above U+00FF in a header, and strips leading and trailing spaces, so this is how such a password is sent. Without this header, `X-Share-Password` is read raw, as it arrives. Any other value, or a password header that is not percent-encoded UTF-8 under `utf-8`, is refused with `400 VALIDATION_FAILED`. |
10151016

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

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,7 +1638,7 @@
16381638
"title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking, the stored password hash never leaves the server, the password travels in the X-Share-Password header, and public answers are never cached",
16391639
"since": "v16",
16401640
"status": "active",
1641-
"revision": 5,
1641+
"revision": 6,
16421642
"priority": "P2",
16431643
"surface": "api",
16441644
"personas": [
@@ -1721,9 +1721,9 @@
17211721
"evidence": "the three traces and the preflight response headers"
17221722
},
17231723
{
1724-
"clause": "public share-link answers are never cached: BOTH public routes (/:token/resolve and /:token/messages) answer Cache-Control: no-store and Vary: X-Share-Password on EVERY outcome, success and refusal alike. The authenticated create, list and revoke routes do not carry these headers",
1724+
"clause": "public share-link answers are never cached: BOTH public routes (/:token/resolve and /:token/messages) answer Cache-Control: no-store and Vary: X-Share-Password, X-Share-Password-Encoding on EVERY outcome, success and refusal alike. The authenticated create, list and revoke routes do not carry these headers",
17251725
"oracle": "api",
1726-
"verify": "each public response's headers carry exactly Cache-Control: no-store and Vary: X-Share-Password (200, 401 x2, 404, 410, and the messages refusal); the authenticated list response carries neither",
1726+
"verify": "each public response's headers carry exactly Cache-Control: no-store and Vary: X-Share-Password, X-Share-Password-Encoding (200, 401 x2, 404, 410, and the messages refusal); the authenticated list response carries neither",
17271727
"evidence": "the header block of every public response and of the authenticated list response"
17281728
}
17291729
],
@@ -1759,7 +1759,8 @@
17591759
"packages/plugins/plugin-sharing/src/share-link-service.ts#withoutPasswordHash (the one exit projection: every copy of a link that leaves the service drops password_hash)",
17601760
"packages/plugins/plugin-sharing/src/share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS (the plugin mount: no-store + Vary on both public routes; the x-share-password header read)",
17611761
"packages/runtime/src/domains/share-links.ts#PUBLIC_RESPONSE_HEADERS (the dispatcher mount: the same headers on every public outcome, including a throw outside the body try)",
1762-
"packages/plugins/plugin-hono-server/src/adapter.ts#DEFAULT_CORS_ALLOW_HEADERS (X-Share-Password in the default preflight allow-list)",
1762+
"packages/plugins/plugin-hono-server/src/adapter.ts#DEFAULT_CORS_ALLOW_HEADERS (X-Share-Password and X-Share-Password-Encoding in the default preflight allow-list)",
1763+
"packages/types/src/share-password-header.ts#readSharePasswordHeader (the one reading of the X-Share-Password header pair both mounts decode through: raw without X-Share-Password-Encoding, percent-encoded UTF-8 under utf-8, 400 VALIDATION_FAILED for a declared encoding that does not hold)",
17631764
"packages/plugins/plugin-sharing/src/share-link-password.ts#hashShareLinkPassword (the stored form is the platform slow password hash; legacy forms still verify and are upgraded on the first successful redemption)",
17641765
"pins: packages/plugins/plugin-sharing/src/share-link-password.test.ts ('[#21839] the stored hash never leaves the server', '[#21839] how the password travels in', '[#21839] the public routes are never cached') · packages/runtime/src/domains/share-links-public-cache-headers.test.ts ('[#21839] dispatcher public share-link routes are never cached') · packages/plugins/plugin-hono-server/src/hono-plugin.test.ts ('should allow X-Share-Password by default')",
17651766
"content/docs/protocol/kernel/http-protocol.mdx (X-Share-Password in the allowed request headers) · #21839 · PR #21890"
@@ -1794,6 +1795,12 @@
17941795
"date": "2026-10-06",
17951796
"change": "three clauses added for the rules PR #21890 landed (#21839), with a step and a negative each: the stored password hash leaves the server on no exit (mint, list, redemption); the password is accepted from the X-Share-Password header (the ?password= form is still accepted, and the default CORS allow-list carries the header); and both public routes answer Cache-Control: no-store and Vary: X-Share-Password on every outcome, while the authenticated routes do not. The messages route is scored on its refusal, since its success half stays the Cloud/EE knownGap. Existing clauses, steps and indices are unchanged",
17961797
"ref": "#21932"
1798+
},
1799+
{
1800+
"revision": 6,
1801+
"date": "2026-10-07",
1802+
"change": "the Vary clause and its verify re-pointed to the measured answer: both public routes now answer Vary: X-Share-Password, X-Share-Password-Encoding, because the answer also depends on the companion header that declares the password header's encoding (utf-8: percent-encoded UTF-8). Sources name the shared header reading and the companion header in the default CORS allow-list. No clause, step, negative or variant added or removed; a clause scoring the encoded form is left to a checklist sweep",
1803+
"ref": "#22049"
17971804
}
17981805
]
17991806
},

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ import {
6767
* `X-Share-Password` carries a share-link password to the public
6868
* `/share-links/:token/resolve` and `/messages` routes — the preferred form,
6969
* since a header stays out of URLs (#21839); without it here a cross-origin
70-
* client could only use the query-parameter form.
70+
* client could only use the query-parameter form. `X-Share-Password-Encoding`
71+
* declares that header's encoding (`utf-8`: percent-encoded UTF-8, #22049) —
72+
* the only way a password with a character above U+00FF can be sent from a
73+
* browser; without it here a cross-origin client could send only the
74+
* passwords a raw header can carry.
7175
*/
7276
export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
7377
'Content-Type',
@@ -77,6 +81,7 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
7781
'X-Environment-Id',
7882
'If-Match',
7983
'X-Share-Password',
84+
'X-Share-Password-Encoding',
8085
]);
8186

8287
/**

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,19 @@ describe('HonoServerPlugin', () => {
324324
expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password');
325325
});
326326

327+
it('should allow X-Share-Password-Encoding by default (the header declaring its encoding, #22049)', async () => {
328+
corsConfigCapture.last = undefined;
329+
330+
const plugin = new HonoServerPlugin();
331+
await plugin.init(context as PluginContext);
332+
333+
// A password with a character above U+00FF travels percent-encoded
334+
// under `X-Share-Password-Encoding: utf-8`; a preflight that does not
335+
// allow the companion leaves a cross-origin client only the passwords
336+
// a raw header can carry.
337+
expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password-Encoding');
338+
});
339+
327340
it('should merge user-supplied exposeHeaders with set-auth-token default', async () => {
328341
corsConfigCapture.last = undefined;
329342

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

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,15 @@
2323
* `?password=` query parameter still is, and a wrong password is refused
2424
* through either form;
2525
* - no log line carries the presented password;
26+
* - [#22049] `X-Share-Password-Encoding: utf-8` declares the header
27+
* percent-encoded UTF-8, so a CJK and an emoji password resolve through it
28+
* on both public routes; without it a Latin-1 password and a raw one
29+
* containing `%` resolve unchanged; a declared encoding that does not hold
30+
* is refused `400 VALIDATION_FAILED` before the token is looked up, never
31+
* compared raw;
2632
* - both public routes answer `Cache-Control: no-store` and
27-
* `Vary: X-Share-Password` on every outcome, and the authenticated routes
28-
* do not;
33+
* `Vary: X-Share-Password, X-Share-Password-Encoding` on every outcome, and
34+
* the authenticated routes do not;
2935
* - the pure-JS scrypt the WebContainer path uses and `node:crypto`'s produce
3036
* interchangeable hashes.
3137
*/
@@ -478,10 +484,127 @@ describe('[#21839] how the password travels in', () => {
478484
});
479485
});
480486

487+
describe('[#22049] the password header declares its encoding', () => {
488+
const CJK = '分享密码二二零四九';
489+
const EMOJI = 'open 🔐🦊 sesame';
490+
const LATIN1 = 'Déjà vu ½ ÿ 22049';
491+
const RAW_PERCENT = 'grade 100% %zz 22049';
492+
const RAW_PERCENT_ESCAPE = '50%25 off 22049';
493+
const UTF8 = { 'x-share-password-encoding': 'utf-8' };
494+
495+
async function protectedConversation(password: string) {
496+
const booted = await boot();
497+
const link = await booted.service.createLink(
498+
{ object: 'ai_conversations', recordId: 'conv_1', password },
499+
CREATOR,
500+
);
501+
return { ...booted, link };
502+
}
503+
504+
function expectServed(route: 'resolve' | 'messages', res: { status: number; body: any }, label: string) {
505+
expect(res.status, label).toBe(200);
506+
expect(res.body?.success, label).toBe(true);
507+
if (route === 'messages') expect(res.body?.data?.map((m: Row) => m.id), label).toEqual(['msg_1']);
508+
else expect(res.body?.data?.record?.id, label).toBe('conv_1');
509+
}
510+
511+
it.each([
512+
['resolve', 'CJK', CJK],
513+
['resolve', 'emoji', EMOJI],
514+
['messages', 'CJK', CJK],
515+
['messages', 'emoji', EMOJI],
516+
] as const)('/%s serves a %s password sent percent-encoded under utf-8', async (route, label, password) => {
517+
const { http, link } = await protectedConversation(password);
518+
const res = await drive(http, `GET ${B}/:token/${route}`, {
519+
params: { token: link.token },
520+
headers: { 'x-share-password': encodeURIComponent(password), ...UTF8 },
521+
});
522+
expectServed(route, res, label);
523+
});
524+
525+
it.each([
526+
['resolve', 401, 'WRONG_PASSWORD'],
527+
['messages', 404, 'NOT_FOUND'],
528+
] as const)('/%s does not guess: the same encoded value with no encoding header is compared raw', async (route, status, code) => {
529+
const { http, link } = await protectedConversation(CJK);
530+
const res = await drive(http, `GET ${B}/:token/${route}`, {
531+
params: { token: link.token },
532+
headers: { 'x-share-password': encodeURIComponent(CJK) },
533+
});
534+
expect(res.status).toBe(status);
535+
expect(res.body?.error?.code).toBe(code);
536+
});
537+
538+
it.each([
539+
['resolve', 'Latin-1', LATIN1],
540+
['resolve', 'raw with a stray %', RAW_PERCENT],
541+
['resolve', 'raw with a %-escape', RAW_PERCENT_ESCAPE],
542+
['messages', 'Latin-1', LATIN1],
543+
['messages', 'raw with a stray %', RAW_PERCENT],
544+
['messages', 'raw with a %-escape', RAW_PERCENT_ESCAPE],
545+
] as const)('/%s still serves a %s password sent raw, with no encoding header', async (route, label, password) => {
546+
const { http, link } = await protectedConversation(password);
547+
const res = await drive(http, `GET ${B}/:token/${route}`, {
548+
params: { token: link.token },
549+
headers: { 'x-share-password': password },
550+
});
551+
expectServed(route, res, label);
552+
});
553+
554+
it.each([
555+
['resolve', 'a truncated UTF-8 sequence', { 'x-share-password': '%E5%88', ...UTF8 }],
556+
['resolve', 'an octet that is not UTF-8', { 'x-share-password': '%FF', ...UTF8 }],
557+
['resolve', 'an encoding the server does not read', { 'x-share-password': encodeURIComponent(CJK), 'x-share-password-encoding': 'latin1' }],
558+
['messages', 'a truncated UTF-8 sequence', { 'x-share-password': '%E5%88', ...UTF8 }],
559+
['messages', 'an octet that is not UTF-8', { 'x-share-password': '%FF', ...UTF8 }],
560+
['messages', 'an encoding the server does not read', { 'x-share-password': encodeURIComponent(CJK), 'x-share-password-encoding': 'latin1' }],
561+
] as const)('/%s refuses %s with 400 VALIDATION_FAILED, before the token is looked up', async (route, _label, headers) => {
562+
const { http, link, service } = await protectedConversation(CJK);
563+
const resolveToken = vi.spyOn(service, 'resolveToken');
564+
const res = await drive(http, `GET ${B}/:token/${route}`, { params: { token: link.token }, headers });
565+
expect(res.status).toBe(400);
566+
expect(res.body?.success).toBe(false);
567+
expect(res.body?.error?.code).toBe('VALIDATION_FAILED');
568+
expect(res.body?.error?.message).toContain('X-Share-Password');
569+
expect(JSON.stringify(res.body)).not.toContain(headers['x-share-password']);
570+
expect(res.headers['Cache-Control']).toBe('no-store');
571+
expect(resolveToken).not.toHaveBeenCalled();
572+
});
573+
574+
it.each(['resolve', 'messages'] as const)(
575+
'/%s never falls back to a raw compare: an undecodable value that IS the raw password is still refused',
576+
async (route) => {
577+
const rawPassword = '%E5%88 22049';
578+
const { http, link } = await protectedConversation(rawPassword);
579+
const raw = await drive(http, `GET ${B}/:token/${route}`, {
580+
params: { token: link.token },
581+
headers: { 'x-share-password': rawPassword },
582+
});
583+
expectServed(route, raw, 'the raw form, undeclared');
584+
const declared = await drive(http, `GET ${B}/:token/${route}`, {
585+
params: { token: link.token },
586+
headers: { 'x-share-password': rawPassword, ...UTF8 },
587+
});
588+
expect(declared.status).toBe(400);
589+
expect(declared.body?.error?.code).toBe('VALIDATION_FAILED');
590+
},
591+
);
592+
593+
it.each(['resolve', 'messages'] as const)('/%s: the ?password= form still wins, and the header pair is then not read', async (route) => {
594+
const { http, link } = await protectedConversation(CJK);
595+
const res = await drive(http, `GET ${B}/:token/${route}`, {
596+
params: { token: link.token },
597+
query: { password: CJK },
598+
headers: { 'x-share-password': '%FF', 'x-share-password-encoding': 'latin1' },
599+
});
600+
expectServed(route, res, 'query wins');
601+
});
602+
});
603+
481604
describe('[#21839] the public routes are never cached', () => {
482605
function expectNoStore(res: { headers: Record<string, string | string[]> }, label: string) {
483606
expect(res.headers['Cache-Control'], label).toBe('no-store');
484-
expect(res.headers.Vary, label).toBe('X-Share-Password');
607+
expect(res.headers.Vary, label).toBe('X-Share-Password, X-Share-Password-Encoding');
485608
}
486609

487610
it.each(['resolve', 'messages'] as const)('/%s sends no-store + Vary on success and on every refusal', async (route) => {

0 commit comments

Comments
 (0)