Skip to content

Commit 5cfd866

Browse files
fix(sharing): X-Share-Password declares its encoding (X-Share-Password-Encoding: utf-8), so any share-link password can be sent from a browser (#22061)
Fixes #22049 Clause-②: yes (widening) **Accepted-set change.** New: a request to `GET /api/v1/share-links/:token/resolve` or `/:token/messages` may carry `X-Share-Password-Encoding: utf-8` (compared case-insensitively) beside `X-Share-Password`. The password header is then read as the password's UTF-8 bytes, percent-encoded (what `encodeURIComponent` produces), so a password with a character above U+00FF, or one that begins or ends with a space, can now be presented through the header. This holds on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Unchanged: a request without `X-Share-Password-Encoding` is read as before, with `X-Share-Password` raw as it arrives (a repeated header by its first value). Every value accepted until now, including a Latin-1 password and a raw password containing `%`, resolves to the same password. When `?password=` is present it still wins, and the header pair is not read at all. Refused: `X-Share-Password-Encoding` with any other value is refused. Before this change that header meant nothing and no client sent it. Also refused is `utf-8` over a password header that is not percent-encoded UTF-8: a `%` without two hex digits, octets that are not well-formed UTF-8, or any character outside visible ASCII. Both answer `400` with code `VALIDATION_FAILED` in the ADR-0112 envelope (`success: false`, `error.code`, `error.message`), on both routes and both mounts. The refusal comes before the token is looked up, and the value is never compared raw instead. `VALIDATION_FAILED` is already registered under both `@objectstack/plugin-sharing` and `@objectstack/runtime`, so no error code is added. ## What changed - **One reading, two producers.** `@objectstack/types` exports `readSharePasswordHeader(passwordHeader, encodingHeader)` and the constants `SHARE_PASSWORD_HEADER`, `SHARE_PASSWORD_ENCODING_HEADER`, `SHARE_PASSWORD_ENCODING_UTF8` and `SHARE_PASSWORD_VARY`. The sharing plugin's `presentedPassword` and the runtime domain's `presentedPassword` both read the header pair through it. On a refusal, each door answers `400 VALIDATION_FAILED` in its own registered vocabulary. The helper spells no error code, because the error-code ledger registers codes per emitting package. It also never trims, logs or echoes the value. - **Response headers.** Both public routes answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. - **CORS.** `DEFAULT_CORS_ALLOW_HEADERS` (`@objectstack/plugin-hono-server`, also applied by `@objectstack/hono`) gains `X-Share-Password-Encoding`. - **Producer docs.** The header docblocks on both mounts, and the `X-Share-Password-Encoding` row in `content/docs/protocol/kernel/http-protocol.mdx` (CORS block and table). The platform checklist item `access-security.share-link-capability-tokens` moves to revision 6, because its Vary clause would otherwise read false. - **One `minor` changeset** for `types`, `plugin-sharing`, `runtime` and `plugin-hono-server`. ## Why a companion header, not an in-value prefix The ruling allowed either form. The RFC 8187-style `UTF-8''` prefix would re-read every raw password that happens to begin with those seven characters. A previously accepted value would then resolve differently, or be refused, which is a narrowing that the declared `(widening)` does not cover. A companion header re-reads nothing that was sent before it existed. Its cost is the second header in the CORS allow-list and in `Vary`. A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` before a cross-origin client can send an encoded password. The changeset says so. ## Landing outside the expected file surface, and why - **`packages/types` is the helper home.** It is the readers' existing common import. `@objectstack/plugin-sharing` is only a dev dependency of the runtime, so importing from the plugin would add a dependency edge, and `packages/spec` is excluded. Both packages already depend on `@objectstack/types`, so the import adds no edge. - **`packages/plugins/plugin-hono-server`: the default CORS allow-list and its pin.** Without it, a cross-origin client could not send the companion header, and the capability would be declared but not delivered. - **`docs/qa/platform-checklist/areas/access-security.json`.** Its Vary clause said "exactly ... Vary: X-Share-Password" and would have read false. Only that clause, its verify text, two source lines and a history entry changed. ## Whitespace (mechanism hypothesis 4), measured on Node 22.22.0 - `new Headers()` turns `' a b \t'` into `'a b'`, and throws a `TypeError` on a CJK value. - Node's http server receives `' p w \t'` as `'p w'`. - The console mints trimmed: `ShareDialog` sends `password.trim()`. The landing page sends the typed value, and the browser's `Headers` strips its leading and trailing HTTP whitespace. - The server compares exactly what arrives: `createLink` hashes the input as is, `resolveToken` verifies it as is, and the new reading does not trim. - So the raw path behaves as before for a space-edged password. Under `utf-8`, a space-edged password now survives transport as `%20`. ## Verification (head `f7cabbb83a`) - **Pins.** On both mounts, `/resolve` and `/messages` serve a CJK and an emoji password sent percent-encoded under `utf-8`. The same encoded value with no encoding header is compared raw (`401 WRONG_PASSWORD` / `404 NOT_FOUND`): the server does not guess. A Latin-1 password, a raw password with a stray `%`, and a raw password with a clean `%25` escape each resolve sent raw. A truncated UTF-8 sequence, `%FF`, and an unknown encoding each get `400 VALIDATION_FAILED`, with `success` false, the public headers present, and `resolveToken` never called. A value that IS the raw password but does not decode under `utf-8` is still refused. `?password=` still wins. - **Files.** `packages/types/src/share-password-header.test.ts` (33 passed), `packages/plugins/plugin-sharing/src/share-link-password.test.ts` (44 passed), `packages/runtime/src/domains/share-links-password-encoding.test.ts` (23 passed), `share-links-public-cache-headers.test.ts` (5 passed), and `hono-plugin.test.ts` (the new CORS pin). - **Package suites.** `types` 24 files / 739 passed; `plugin-sharing` 40 / 1002; `runtime` (`--project local`) 332 / 4693 passed, 19 skipped; `plugin-hono-server` 27 / 326. `typecheck` is green on all four, including each package's `check:test-typecheck`. - **Ablations.** Each mutation was landed by `scripts/ablation-replace.mjs` (anchor hit and blob change proven, restore proven against the HEAD blob), with `@objectstack/types` rebuilt and `scripts/ablation-dist-preflight.mjs` proving the marker in `dist/`, then absent after the restore leg. Every leg turned red as predicted and went back to green: - malformed value falls through to a raw compare: types 8, sharing 6, runtime 6 red; - unknown encoding ignored: 6 / 2 / 2 red; - every value percent-decoded (guessing): 4 / 8 / 8 red; - the sharing door drops its 400: sharing 8 red; - the runtime door drops its 400: runtime 8 red. - **End to end** on a throwaway showcase server (`pnpm dev -- --fresh`, random port, torn down). Four links were minted over `POST /api/v1/share-links`. - Served (200): a CJK and an emoji password under `utf-8` / `UTF-8`, a raw Latin-1 byte (`caf` plus byte E9), and a raw `50%25 off`. - Not guessed: the encoded CJK value with no encoding header gets `401 WRONG_PASSWORD`. - Refused: `%E5%88` under `utf-8`, the `latin1` encoding, and `%FF` on `/messages` each get `400 VALIDATION_FAILED`. - `/messages` with the CJK password got past the password to `400 UNSUPPORTED`, because the record is not a conversation. - A cross-origin preflight answers `Access-Control-Allow-Headers` naming both headers. - **Gates.** `node scripts/pm/dispatch-gates.mjs` re-derived 97 commands on this change and all 97 exit 0. Three first exited 3 (`PREREQUISITE NOT MET`: `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`), and after `turbo run build` they exit 0. `--ran` reconciliation: 97 derived, 97 run, 0 NOT-MEASURED, a derived zero. The share-link dogfood files `share-links-self-list.dogfood.test.ts` and `showcase-client-liaison-fixtures.dogfood.test.ts` pass (16 tests). - **Lint, narrowed.** The 10 changed `.ts` files are the whole population the `**/*.{ts,…}` globs in `eslint.config.mjs` select from this diff; the other 3 files are `.md`, `.mdx` and `.json`. Run with `--no-inline-config --format json`: 10 files, 0 errors, 0 warnings. Type-aware linting is not enabled in that config (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - `origin/main` moved 3 commits past the base `a7a48b784d`, and none of them touches these packages or files. ## Acceptance notes - The console's encode half follows on objectui once a release carries this decode. It is not this PR. When it lands, it should trim the way `ShareDialog` mints. JS `trim()` also removes U+00A0 and U+3000, which the browser's header stripping never did, so an encoded password keeps characters that a raw send would have lost. - No checklist clause yet scores the encoded form itself. That is left to a checklist sweep. - #21839 is the parent; its `?password=` read is untouched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56c8844 commit 5cfd866

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)