Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/22049-share-password-header-encoding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/types": minor
"@objectstack/plugin-sharing": minor
"@objectstack/runtime": minor
"@objectstack/plugin-hono-server": minor
"@objectstack/hono": minor
---

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`)

Clause-②: yes (widening)

- **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.
- **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.
- **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.
- **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.
- **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`.
- **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.
5 changes: 3 additions & 2 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -999,19 +999,20 @@ ObjectStack sends CORS headers automatically:
```http
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, X-Share-Password
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
Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields
Access-Control-Max-Age: 86400
```

Four of the allowed request headers are easy to overlook, and each one disables
Five of the allowed request headers are easy to overlook, and each one disables
a feature if an intermediate proxy strips it:

| Header | Why it is allowed |
| --- | --- |
| `X-Tenant-ID` / `X-Environment-Id` | Route the request to its environment on a multi-tenant host. |
| `If-Match` | Carries the OCC token on record `PATCH`es. Without it, a cross-origin save fails in the browser with "Failed to fetch". |
| `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. |
| `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`. |

The two **exposed** response headers matter to browser clients specifically:
`set-auth-token` delivers a rotated session token (without it a cross-origin
Expand Down
15 changes: 11 additions & 4 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -1638,7 +1638,7 @@
"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",
"since": "v16",
"status": "active",
"revision": 5,
"revision": 6,
"priority": "P2",
"surface": "api",
"personas": [
Expand Down Expand Up @@ -1721,9 +1721,9 @@
"evidence": "the three traces and the preflight response headers"
},
{
"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",
"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",
"oracle": "api",
"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",
"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",
"evidence": "the header block of every public response and of the authenticated list response"
}
],
Expand Down Expand Up @@ -1759,7 +1759,8 @@
"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)",
"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)",
"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)",
"packages/plugins/plugin-hono-server/src/adapter.ts#DEFAULT_CORS_ALLOW_HEADERS (X-Share-Password in the default preflight allow-list)",
"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)",
"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)",
"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)",
"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')",
"content/docs/protocol/kernel/http-protocol.mdx (X-Share-Password in the allowed request headers) · #21839 · PR #21890"
Expand Down Expand Up @@ -1794,6 +1795,12 @@
"date": "2026-10-06",
"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",
"ref": "#21932"
},
{
"revision": 6,
"date": "2026-10-07",
"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",
"ref": "#22049"
}
]
},
Expand Down
7 changes: 6 additions & 1 deletion packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ import {
* `X-Share-Password` carries a share-link password to the public
* `/share-links/:token/resolve` and `/messages` routes — the preferred form,
* since a header stays out of URLs (#21839); without it here a cross-origin
* client could only use the query-parameter form.
* client could only use the query-parameter form. `X-Share-Password-Encoding`
* declares that header's encoding (`utf-8`: percent-encoded UTF-8, #22049) —
* the only way a password with a character above U+00FF can be sent from a
* browser; without it here a cross-origin client could send only the
* passwords a raw header can carry.
*/
export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
'Content-Type',
Expand All @@ -77,6 +81,7 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
'X-Environment-Id',
'If-Match',
'X-Share-Password',
'X-Share-Password-Encoding',
]);

/**
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,19 @@ describe('HonoServerPlugin', () => {
expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password');
});

it('should allow X-Share-Password-Encoding by default (the header declaring its encoding, #22049)', async () => {
corsConfigCapture.last = undefined;

const plugin = new HonoServerPlugin();
await plugin.init(context as PluginContext);

// A password with a character above U+00FF travels percent-encoded
// under `X-Share-Password-Encoding: utf-8`; a preflight that does not
// allow the companion leaves a cross-origin client only the passwords
// a raw header can carry.
expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password-Encoding');
});

it('should merge user-supplied exposeHeaders with set-auth-token default', async () => {
corsConfigCapture.last = undefined;

Expand Down
129 changes: 126 additions & 3 deletions packages/plugins/plugin-sharing/src/share-link-password.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@
* `?password=` query parameter still is, and a wrong password is refused
* through either form;
* - no log line carries the presented password;
* - [#22049] `X-Share-Password-Encoding: utf-8` declares the header
* percent-encoded UTF-8, so a CJK and an emoji password resolve through it
* on both public routes; without it a Latin-1 password and a raw one
* containing `%` resolve unchanged; a declared encoding that does not hold
* is refused `400 VALIDATION_FAILED` before the token is looked up, never
* compared raw;
* - both public routes answer `Cache-Control: no-store` and
* `Vary: X-Share-Password` on every outcome, and the authenticated routes
* do not;
* `Vary: X-Share-Password, X-Share-Password-Encoding` on every outcome, and
* the authenticated routes do not;
* - the pure-JS scrypt the WebContainer path uses and `node:crypto`'s produce
* interchangeable hashes.
*/
Expand Down Expand Up @@ -478,10 +484,127 @@ describe('[#21839] how the password travels in', () => {
});
});

describe('[#22049] the password header declares its encoding', () => {
const CJK = '分享密码二二零四九';
const EMOJI = 'open 🔐🦊 sesame';
const LATIN1 = 'Déjà vu ½ ÿ 22049';
const RAW_PERCENT = 'grade 100% %zz 22049';
const RAW_PERCENT_ESCAPE = '50%25 off 22049';
const UTF8 = { 'x-share-password-encoding': 'utf-8' };

async function protectedConversation(password: string) {
const booted = await boot();
const link = await booted.service.createLink(
{ object: 'ai_conversations', recordId: 'conv_1', password },
CREATOR,
);
return { ...booted, link };
}

function expectServed(route: 'resolve' | 'messages', res: { status: number; body: any }, label: string) {
expect(res.status, label).toBe(200);
expect(res.body?.success, label).toBe(true);
if (route === 'messages') expect(res.body?.data?.map((m: Row) => m.id), label).toEqual(['msg_1']);
else expect(res.body?.data?.record?.id, label).toBe('conv_1');
}

it.each([
['resolve', 'CJK', CJK],
['resolve', 'emoji', EMOJI],
['messages', 'CJK', CJK],
['messages', 'emoji', EMOJI],
] as const)('/%s serves a %s password sent percent-encoded under utf-8', async (route, label, password) => {
const { http, link } = await protectedConversation(password);
const res = await drive(http, `GET ${B}/:token/${route}`, {
params: { token: link.token },
headers: { 'x-share-password': encodeURIComponent(password), ...UTF8 },
});
expectServed(route, res, label);
});

it.each([
['resolve', 401, 'WRONG_PASSWORD'],
['messages', 404, 'NOT_FOUND'],
] as const)('/%s does not guess: the same encoded value with no encoding header is compared raw', async (route, status, code) => {
const { http, link } = await protectedConversation(CJK);
const res = await drive(http, `GET ${B}/:token/${route}`, {
params: { token: link.token },
headers: { 'x-share-password': encodeURIComponent(CJK) },
});
expect(res.status).toBe(status);
expect(res.body?.error?.code).toBe(code);
});

it.each([
['resolve', 'Latin-1', LATIN1],
['resolve', 'raw with a stray %', RAW_PERCENT],
['resolve', 'raw with a %-escape', RAW_PERCENT_ESCAPE],
['messages', 'Latin-1', LATIN1],
['messages', 'raw with a stray %', RAW_PERCENT],
['messages', 'raw with a %-escape', RAW_PERCENT_ESCAPE],
] as const)('/%s still serves a %s password sent raw, with no encoding header', async (route, label, password) => {
const { http, link } = await protectedConversation(password);
const res = await drive(http, `GET ${B}/:token/${route}`, {
params: { token: link.token },
headers: { 'x-share-password': password },
});
expectServed(route, res, label);
});

it.each([
['resolve', 'a truncated UTF-8 sequence', { 'x-share-password': '%E5%88', ...UTF8 }],
['resolve', 'an octet that is not UTF-8', { 'x-share-password': '%FF', ...UTF8 }],
['resolve', 'an encoding the server does not read', { 'x-share-password': encodeURIComponent(CJK), 'x-share-password-encoding': 'latin1' }],
['messages', 'a truncated UTF-8 sequence', { 'x-share-password': '%E5%88', ...UTF8 }],
['messages', 'an octet that is not UTF-8', { 'x-share-password': '%FF', ...UTF8 }],
['messages', 'an encoding the server does not read', { 'x-share-password': encodeURIComponent(CJK), 'x-share-password-encoding': 'latin1' }],
] as const)('/%s refuses %s with 400 VALIDATION_FAILED, before the token is looked up', async (route, _label, headers) => {
const { http, link, service } = await protectedConversation(CJK);
const resolveToken = vi.spyOn(service, 'resolveToken');
const res = await drive(http, `GET ${B}/:token/${route}`, { params: { token: link.token }, headers });
expect(res.status).toBe(400);
expect(res.body?.success).toBe(false);
expect(res.body?.error?.code).toBe('VALIDATION_FAILED');
expect(res.body?.error?.message).toContain('X-Share-Password');
expect(JSON.stringify(res.body)).not.toContain(headers['x-share-password']);
expect(res.headers['Cache-Control']).toBe('no-store');
expect(resolveToken).not.toHaveBeenCalled();
});

it.each(['resolve', 'messages'] as const)(
'/%s never falls back to a raw compare: an undecodable value that IS the raw password is still refused',
async (route) => {
const rawPassword = '%E5%88 22049';
const { http, link } = await protectedConversation(rawPassword);
const raw = await drive(http, `GET ${B}/:token/${route}`, {
params: { token: link.token },
headers: { 'x-share-password': rawPassword },
});
expectServed(route, raw, 'the raw form, undeclared');
const declared = await drive(http, `GET ${B}/:token/${route}`, {
params: { token: link.token },
headers: { 'x-share-password': rawPassword, ...UTF8 },
});
expect(declared.status).toBe(400);
expect(declared.body?.error?.code).toBe('VALIDATION_FAILED');
},
);

it.each(['resolve', 'messages'] as const)('/%s: the ?password= form still wins, and the header pair is then not read', async (route) => {
const { http, link } = await protectedConversation(CJK);
const res = await drive(http, `GET ${B}/:token/${route}`, {
params: { token: link.token },
query: { password: CJK },
headers: { 'x-share-password': '%FF', 'x-share-password-encoding': 'latin1' },
});
expectServed(route, res, 'query wins');
});
});

describe('[#21839] the public routes are never cached', () => {
function expectNoStore(res: { headers: Record<string, string | string[]> }, label: string) {
expect(res.headers['Cache-Control'], label).toBe('no-store');
expect(res.headers.Vary, label).toBe('X-Share-Password');
expect(res.headers.Vary, label).toBe('X-Share-Password, X-Share-Password-Encoding');
}

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