From 33ae0de80648e522270dbcf12675e666f46f6a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:41:03 +0000 Subject: [PATCH 1/5] feat(types): one reading of the share-link password header pair, with a signalled encoding X-Share-Password-Encoding: utf-8 declares X-Share-Password percent-encoded UTF-8; without it the password header is read raw, unchanged. A declared encoding that does not hold is a refusal, never a raw compare. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- packages/types/src/index.ts | 7 + .../types/src/share-password-header.test.ts | 118 ++++++++++++++ packages/types/src/share-password-header.ts | 147 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 packages/types/src/share-password-header.test.ts create mode 100644 packages/types/src/share-password-header.ts diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ff6a46d478b..0e902ffde1f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -89,6 +89,13 @@ export * from './unique-scope-install-gate.js'; // judges a failed row with the same table the HTTP door answers with. `rest` // keeps the emission half and re-exports the public names. export * from './data-error-classification.js'; +// [#22049] The one reading of a share-link password's request header pair +// (`X-Share-Password` + the `X-Share-Password-Encoding` that declares it), for +// the two mounts of the public share-link routes: `plugin-sharing`'s routes and +// the runtime dispatcher's `/share-links` domain. The runtime has the plugin as +// a dev dependency only, so the plugin cannot be the home; both already depend +// on this package, so adopting the reading adds no edge. +export * from './share-password-header.js'; // Placeholder for Kernel interface to avoid circular dependency // The actual Kernel implementation will satisfy this interface. diff --git a/packages/types/src/share-password-header.test.ts b/packages/types/src/share-password-header.test.ts new file mode 100644 index 00000000000..6be7a9135f6 --- /dev/null +++ b/packages/types/src/share-password-header.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22049] The one reading of a share-link password's header pair. Both mounts + * of the public share-link routes stand on it (`plugin-sharing`'s routes and + * the runtime dispatcher's `/share-links` domain pin the same cases end to end), + * so the rule is pinned HERE first, case by case: + * + * - no encoding header: the password header is read raw, exactly as before — + * a Latin-1 password and one containing `%` included; + * - `X-Share-Password-Encoding: utf-8` (any case): percent-encoded UTF-8 is + * decoded, a CJK, an emoji and a space-edged password included; + * - a declared encoding that does not hold, or any other encoding, is a + * refusal whose message never carries the presented value. + */ + +import { describe, it, expect } from 'vitest'; +import { + readSharePasswordHeader, + SHARE_PASSWORD_ENCODING_HEADER, + SHARE_PASSWORD_ENCODING_UTF8, + SHARE_PASSWORD_HEADER, + SHARE_PASSWORD_VARY, +} from './share-password-header.js'; + +const CJK = '分享密码二二零四九'; +const EMOJI = 'open 🔐🦊 sesame'; +const LATIN1 = 'Déjà vu ½ ÿ'; +const RAW_PERCENT = '100%25 sure %zz %'; + +describe('[#22049] the header names', () => { + it('names the password header, its companion and the Vary value both doors send', () => { + expect(SHARE_PASSWORD_HEADER).toBe('X-Share-Password'); + expect(SHARE_PASSWORD_ENCODING_HEADER).toBe('X-Share-Password-Encoding'); + expect(SHARE_PASSWORD_ENCODING_UTF8).toBe('utf-8'); + expect(SHARE_PASSWORD_VARY).toBe('X-Share-Password, X-Share-Password-Encoding'); + }); +}); + +describe('[#22049] without the encoding header the value is read raw, unchanged', () => { + it.each([ + ['ASCII', 'correct horse battery'], + ['Latin-1', LATIN1], + ['raw with %', RAW_PERCENT], + ['raw that looks percent-encoded', '%E5%88%86'], + ['raw that looks RFC 8187-prefixed', "UTF-8''%E5%88%86"], + ['empty', ''], + ])('%s', (_label, value) => { + expect(readSharePasswordHeader(value, undefined)).toEqual({ ok: true, password: value }); + expect(readSharePasswordHeader(value, null)).toEqual({ ok: true, password: value }); + }); + + it('a repeated header is read by its first value, an absent one as no password', () => { + expect(readSharePasswordHeader(['first', 'second'], undefined)).toEqual({ ok: true, password: 'first' }); + expect(readSharePasswordHeader(undefined, undefined)).toEqual({ ok: true, password: undefined }); + expect(readSharePasswordHeader(42, undefined)).toEqual({ ok: true, password: undefined }); + }); +}); + +describe('[#22049] X-Share-Password-Encoding: utf-8 decodes percent-encoded UTF-8', () => { + it.each([ + ['CJK', CJK], + ['emoji', EMOJI], + ['Latin-1', LATIN1], + ['raw with %', RAW_PERCENT], + ['space-edged', ' spaced out '], + ['ASCII', 'correct horse battery'], + ])('%s round-trips through encodeURIComponent', (_label, password) => { + expect(readSharePasswordHeader(encodeURIComponent(password), 'utf-8')).toEqual({ ok: true, password }); + }); + + it('the encoding name is compared case-insensitively, a repeated header by its first value', () => { + for (const name of ['UTF-8', 'Utf-8', ['utf-8', 'latin1']]) { + expect(readSharePasswordHeader(encodeURIComponent(CJK), name)).toEqual({ ok: true, password: CJK }); + } + }); + + it('a declared encoding with no password header presents no password', () => { + expect(readSharePasswordHeader(undefined, 'utf-8')).toEqual({ ok: true, password: undefined }); + }); + + it('a plus is a literal plus, never a space', () => { + expect(readSharePasswordHeader('a+b', 'utf-8')).toEqual({ ok: true, password: 'a+b' }); + }); +}); + +describe('[#22049] a declared encoding that does not hold is refused, never read raw', () => { + it.each([ + ['a lone %', '100%'], + ['% not followed by two hex digits', '%zz'], + ['a truncated UTF-8 sequence', '%E5%88'], + ['an octet that never starts UTF-8', '%FF'], + ['an overlong form', '%C0%80'], + ['an encoded surrogate', '%ED%A0%80'], + ['a raw Latin-1 character', 'Déjà'], + ['a raw space', 'two words'], + ])('%s', (_label, value) => { + const reading = readSharePasswordHeader(value, 'utf-8'); + expect(reading.ok).toBe(false); + if (reading.ok) return; + expect(reading.reason).toBe('malformed-value'); + expect(reading.message).toContain('X-Share-Password'); + expect(reading.message).not.toContain(value); + }); + + it.each([['latin1'], ['base64'], ['utf8'], [''], ['utf-8, utf-8']])('an encoding header naming %j', (name) => { + const reading = readSharePasswordHeader(encodeURIComponent(CJK), name); + expect(reading.ok).toBe(false); + if (reading.ok) return; + expect(reading.reason).toBe('unknown-encoding'); + expect(reading.message).toContain('X-Share-Password-Encoding'); + expect(reading.message).not.toContain(encodeURIComponent(CJK)); + }); + + it('an unknown encoding is refused even when no password header came with it', () => { + expect(readSharePasswordHeader(undefined, 'latin1')).toMatchObject({ ok: false, reason: 'unknown-encoding' }); + }); +}); diff --git a/packages/types/src/share-password-header.ts b/packages/types/src/share-password-header.ts new file mode 100644 index 00000000000..76e4fb6a8b1 --- /dev/null +++ b/packages/types/src/share-password-header.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22049] How a share-link password travels in a request header — the ONE + * reading both mounts of the public share-link routes (`/:token/resolve` and + * `/:token/messages`) decode the header pair through: `plugin-sharing`'s + * `presentedPassword` and the runtime dispatcher's `/share-links` domain. + * + * ## Why an encoding has to be declared at all + * + * A header value is a byte string. The Fetch standard's `Headers` refuses, with + * a `TypeError` and before any request leaves, a value with a character above + * U+00FF — so a CJK or emoji password could not be sent from a browser, while + * `createLink` hashes any string and the console's share dialog mints any + * string. Both ends also strip leading and trailing whitespace from a header + * value, so a password that begins or ends with a space could not travel raw + * either. + * + * ## The encoding is SIGNALLED, never guessed + * + * {@link SHARE_PASSWORD_ENCODING_HEADER} is a companion request header naming + * how {@link SHARE_PASSWORD_HEADER} is encoded. Its one accepted value is + * {@link SHARE_PASSWORD_ENCODING_UTF8}, compared case-insensitively: the + * password header then carries the password's UTF-8 bytes, percent-encoded — + * exactly what `encodeURIComponent(password)` produces (`+` is a literal plus, + * never a space). + * + * Without the companion header the password header is read exactly as it + * always was: raw, as it arrives. That is what keeps every value accepted + * before this reading unchanged — a raw Latin-1 password, and a raw password + * containing `%`, still resolve as themselves. Percent-decoding every value + * would have silently re-read those, and an in-value prefix (an RFC 8187-style + * `UTF-8''`) would have re-read every raw password that happened to begin with + * it; a separate header re-reads nothing that was sent before it existed. + * + * ## A declared encoding that does not hold is refused, never fallen through + * + * A companion header naming any other encoding, or a password header that is + * not percent-encoded UTF-8 under it, is a {@link SharePasswordHeaderRefusal}. + * Each door answers it `400` in its own registered vocabulary + * (`VALIDATION_FAILED`, which both packages already list), BEFORE the token is + * looked up, so the refusal says nothing about the link. ⛔ Never compare the + * undecodable value raw instead: the client declared it encoded, and a raw + * compare would answer `401 WRONG_PASSWORD` for what is a malformed request. + * + * This module spells no error code on purpose: the code is the emitting door's + * (the error-code ledger registers it per package), the reading is shared. + * + * Nothing here trims, logs or echoes the value: a refusal message names the + * headers and the rule, never the presented password. + */ + +/** The request header a share-link password travels in. Header names are case-insensitive. */ +export const SHARE_PASSWORD_HEADER = 'X-Share-Password'; + +/** + * The companion request header that declares how {@link SHARE_PASSWORD_HEADER} + * is encoded. Absent, the password header is read raw. + */ +export const SHARE_PASSWORD_ENCODING_HEADER = 'X-Share-Password-Encoding'; + +/** + * The one encoding {@link SHARE_PASSWORD_ENCODING_HEADER} may name (compared + * case-insensitively): the password's UTF-8 bytes, percent-encoded, as + * `encodeURIComponent` produces them. + */ +export const SHARE_PASSWORD_ENCODING_UTF8 = 'utf-8'; + +/** + * The `Vary` value both public share-link routes answer with: the answer + * depends on the password header AND on the header that declares its encoding. + */ +export const SHARE_PASSWORD_VARY = `${SHARE_PASSWORD_HEADER}, ${SHARE_PASSWORD_ENCODING_HEADER}`; + +/** Why a header pair was refused. */ +export type SharePasswordHeaderRefusalReason = 'unknown-encoding' | 'malformed-value'; + +/** A header pair the door must refuse with `400`. `message` never carries the presented value. */ +export interface SharePasswordHeaderRefusal { + readonly ok: false; + readonly reason: SharePasswordHeaderRefusalReason; + readonly message: string; +} + +/** The password the header pair presented — `undefined` when the password header is absent. */ +export interface SharePasswordHeaderPassword { + readonly ok: true; + readonly password: string | undefined; +} + +export type SharePasswordHeaderReading = SharePasswordHeaderPassword | SharePasswordHeaderRefusal; + +const UNKNOWN_ENCODING_MESSAGE = + `${SHARE_PASSWORD_ENCODING_HEADER} names an encoding this server does not read. ` + + `The one accepted value is "${SHARE_PASSWORD_ENCODING_UTF8}": ${SHARE_PASSWORD_HEADER} then carries ` + + `the password's UTF-8 bytes, percent-encoded. Leave ${SHARE_PASSWORD_ENCODING_HEADER} out to send the password raw.`; + +const MALFORMED_VALUE_MESSAGE = + `${SHARE_PASSWORD_HEADER} is not percent-encoded UTF-8, which ` + + `"${SHARE_PASSWORD_ENCODING_HEADER}: ${SHARE_PASSWORD_ENCODING_UTF8}" declares it to be. ` + + `Send the password's UTF-8 bytes percent-encoded, as encodeURIComponent produces them.`; + +/** + * A percent-encoded value is visible ASCII by construction: `encodeURIComponent` + * escapes everything else, a space included. A character outside that range is + * a value that was never encoded, refused rather than passed through. + */ +const PERCENT_ENCODED_CHARS = /^[\x21-\x7E]*$/; + +/** One header value as both doors hand it over: the first of a repeated header, as the raw read always took it. */ +function firstValue(value: unknown): string | undefined { + const first = Array.isArray(value) ? value[0] : value; + return typeof first === 'string' ? first : undefined; +} + +/** + * Read the password a request presented in {@link SHARE_PASSWORD_HEADER}, as + * declared by {@link SHARE_PASSWORD_ENCODING_HEADER}. + * + * @param passwordHeader the raw `x-share-password` value (a repeated header's + * array is read by its first value, as before). + * @param encodingHeader the raw `x-share-password-encoding` value. + * + * Absent encoding header: the password header, raw and unchanged. Encoding + * header `utf-8` (any case): the password header percent-decoded as UTF-8. + * Any other encoding header, or a value that does not decode: a refusal. + */ +export function readSharePasswordHeader(passwordHeader: unknown, encodingHeader: unknown): SharePasswordHeaderReading { + const value = firstValue(passwordHeader); + const encoding = firstValue(encodingHeader); + if (encoding === undefined) return { ok: true, password: value }; + if (encoding.toLowerCase() !== SHARE_PASSWORD_ENCODING_UTF8) { + return { ok: false, reason: 'unknown-encoding', message: UNKNOWN_ENCODING_MESSAGE }; + } + if (value === undefined) return { ok: true, password: undefined }; + if (!PERCENT_ENCODED_CHARS.test(value)) { + return { ok: false, reason: 'malformed-value', message: MALFORMED_VALUE_MESSAGE }; + } + try { + return { ok: true, password: decodeURIComponent(value) }; + } catch { + // `decodeURIComponent` throws `URIError` on a `%` not followed by two hex + // digits and on octets that are not well-formed UTF-8 (a truncated + // sequence, `%FF`, an overlong form, an encoded surrogate). + return { ok: false, reason: 'malformed-value', message: MALFORMED_VALUE_MESSAGE }; + } +} From 54fa0c84a537f528529ad3e6925963ccec1b2793 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:43:32 +0000 Subject: [PATCH 2/5] fix(sharing): both share-link mounts read X-Share-Password as X-Share-Password-Encoding declares it plugin-sharing's presentedPassword and the runtime /share-links domain decode the header pair through readSharePasswordHeader; a declared encoding that does not hold answers 400 VALIDATION_FAILED before the token is looked up. Vary names both headers; the default CORS allow-list carries the companion. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../docs/protocol/kernel/http-protocol.mdx | 5 +- .../plugins/plugin-hono-server/src/adapter.ts | 7 +- .../src/hono-plugin.test.ts | 13 ++ .../src/share-link-password.test.ts | 126 +++++++++- .../plugin-sharing/src/share-link-routes.ts | 47 +++- .../share-links-password-encoding.test.ts | 218 ++++++++++++++++++ .../share-links-public-cache-headers.test.ts | 8 +- packages/runtime/src/domains/share-links.ts | 43 +++- 8 files changed, 437 insertions(+), 30 deletions(-) create mode 100644 packages/runtime/src/domains/share-links-password-encoding.test.ts diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index ec6751b48d4..e6a90de3608 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -999,12 +999,12 @@ 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 | @@ -1012,6 +1012,7 @@ a feature if an intermediate proxy strips it: | `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 diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 5b951a50478..9b1e38dc720 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -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', @@ -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', ]); /** diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index b4a89dc4e4c..a7994094adb 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -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; diff --git a/packages/plugins/plugin-sharing/src/share-link-password.test.ts b/packages/plugins/plugin-sharing/src/share-link-password.test.ts index 17dff588d01..bde92e43413 100644 --- a/packages/plugins/plugin-sharing/src/share-link-password.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-password.test.ts @@ -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. */ @@ -478,10 +484,124 @@ 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 = '100%25 sure %zz %'; + 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 %', RAW_PERCENT], + ['messages', 'Latin-1', LATIN1], + ['messages', 'raw with %', RAW_PERCENT], + ] 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 }, 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) => { diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.ts b/packages/plugins/plugin-sharing/src/share-link-routes.ts index 700da3cacf0..2be99e06c58 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.ts @@ -33,6 +33,9 @@ import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; +// [#22049] The one reading of the password header pair, shared with the +// dispatcher twin — see `presentedPassword` below. +import { readSharePasswordHeader, SHARE_PASSWORD_VARY, type SharePasswordHeaderReading } from '@objectstack/types'; import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; // [#14637 -> #14935] `isPublicSharingEnabled` is the CANONICAL reading of the @@ -138,15 +141,31 @@ function isAuthenticated(ctx: ShareLinkExecutionContext): boolean { * query parameter alone until this helper; it now takes the header too, as * its twin always has. * + * [#22049] The header's encoding is SIGNALLED by a companion header, + * `X-Share-Password-Encoding`, and read through `readSharePasswordHeader` + * (`@objectstack/types`) — the same reading the dispatcher twin uses: + * + * - absent, `X-Share-Password` is read raw, exactly as before — any value a + * client sent until now resolves unchanged; + * - `utf-8` (any case), `X-Share-Password` carries the password's UTF-8 bytes + * percent-encoded (`encodeURIComponent(password)`), so a password with a + * character above U+00FF, or one that begins or ends with a space, can be + * sent from a browser, whose `Headers` refuses the first and strips the + * second; + * - any other encoding, or a value that does not decode under `utf-8`, is a + * refusal both routes answer `400 VALIDATION_FAILED`, before the token is + * looked up. ⛔ Never compared raw instead. + * + * The `?password=` query parameter is a URL and needs no declared encoding; when + * it wins, the header pair is not read at all. + * * Nothing on this path logs either form: the routes write no log line, and * the service's log lines name the link, never the presented password. */ -function presentedPassword(req: IHttpRequest): string | undefined { +function presentedPassword(req: IHttpRequest): SharePasswordHeaderReading { const q: any = req.query ?? {}; - if (typeof q.password === 'string') return q.password; - const v = req.headers?.['x-share-password']; - const header = Array.isArray(v) ? v[0] : v; - return typeof header === 'string' ? header : undefined; + if (typeof q.password === 'string') return { ok: true, password: q.password }; + return readSharePasswordHeader(req.headers?.['x-share-password'], req.headers?.['x-share-password-encoding']); } /** @@ -156,14 +175,16 @@ function presentedPassword(req: IHttpRequest): string | undefined { * `Cache-Control: no-store` — the body is a record released by a capability * token (and, for a protected link, by a password); no browser or shared cache * may keep a copy of it, nor of a refusal that would be replayed after the - * link changes. `Vary: X-Share-Password` — the answer depends on that request - * header, so any cache that does not honour `no-store` must at least never + * link changes. `Vary: X-Share-Password, X-Share-Password-Encoding` — the + * answer depends on that request header and on the one declaring its encoding + * (#22049), so any cache that does not honour `no-store` must at least never * serve one presenter's answer to another. The dispatcher twin - * (`runtime/src/domains/share-links.ts`) sends the same pair. + * (`runtime/src/domains/share-links.ts`) sends the same pair, from the same + * `SHARE_PASSWORD_VARY` constant. */ const SHARE_LINK_PUBLIC_RESPONSE_HEADERS: Readonly> = Object.freeze({ 'Cache-Control': 'no-store', - Vary: 'X-Share-Password', + Vary: SHARE_PASSWORD_VARY, }); function setPublicResponseHeaders(res: IHttpResponse): void { @@ -277,7 +298,9 @@ export function registerShareLinkRoutes( // the "must be signed in" check by inventing a user id. const signedInUserId = (await ctxOf(req)).userId; const recipientEmail = typeof q.email === 'string' ? q.email : undefined; - const providedPassword = presentedPassword(req); + const presented = presentedPassword(req); + if (!presented.ok) return sendError(res, 400, 'VALIDATION_FAILED', presented.message); + const providedPassword = presented.password; const resolved = await service.resolveToken(req.params.token, { signedInUserId, @@ -405,8 +428,10 @@ export function registerShareLinkRoutes( http.get(`${base}/:token/messages`, (async (req, res) => { setPublicResponseHeaders(res); try { + const presented = presentedPassword(req); + if (!presented.ok) return sendError(res, 400, 'VALIDATION_FAILED', presented.message); const resolved = await service.resolveToken(req.params.token, { - providedPassword: presentedPassword(req), + providedPassword: presented.password, }); if (!resolved) { sendError(res, 404, 'NOT_FOUND', 'Share link not found'); diff --git a/packages/runtime/src/domains/share-links-password-encoding.test.ts b/packages/runtime/src/domains/share-links-password-encoding.test.ts new file mode 100644 index 00000000000..bca13c87cf7 --- /dev/null +++ b/packages/runtime/src/domains/share-links-password-encoding.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22049] The dispatcher's two PUBLIC share-link routes read the password + * header as `X-Share-Password-Encoding` declares it — the same reading + * (`readSharePasswordHeader`, `@objectstack/types`) the plugin-sharing mount + * uses, pinned there in `plugin-sharing/src/share-link-password.test.ts`: + * + * - `utf-8`: the header carries the password percent-encoded, so a CJK and an + * emoji password resolve on `/resolve` and `/messages`; + * - absent: the header is read raw, so a Latin-1 password and a raw one + * containing `%` resolve unchanged, and an encoded value is NOT guessed at; + * - a declared encoding that does not hold, or any other encoding: `400 + * VALIDATION_FAILED` in the ADR-0112 envelope, before the token is looked + * up, ⛔ never a raw compare. + * + * Driven over a REAL `ObjectQL` + `@objectstack/driver-sql` + better-sqlite3, + * the harness `share-links-public-cache-headers.test.ts` uses, with the request + * headers on `context.request` the way the dispatcher plugin hands them over. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; +import { ShareLinkService, SysShareLink } from '@objectstack/plugin-sharing'; +import { apiErrorResponse } from '../error-envelope.js'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { handleShareLinksRequest } from './share-links.js'; + +const CJK = '分享密码二二零四九'; +const EMOJI = 'open 🔐🦊 sesame'; +const LATIN1 = 'Déjà vu ½ ÿ 22049'; +const RAW_PERCENT = '100%25 sure %zz %'; + +const CONVERSATIONS = { + name: 'ai_conversations', + label: 'Conversation', + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' }, + }, +}; + +const MESSAGES = { + name: 'ai_messages', + label: 'Message', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + conversation_id: { name: 'conversation_id', label: 'Conversation', type: 'text' }, + content: { name: 'content', label: 'Content', type: 'text' }, + created_at: { name: 'created_at', label: 'Created', type: 'text' }, + }, +}; + +const realErrorFromThrown = (() => { + const dispatcher: any = new HttpDispatcher({ context: { getService: () => null } } as any); + return (e: any, fallbackStatus?: number) => dispatcher.errorFromThrown(e, fallbackStatus); +})(); + +function makeDeps(engine: any, svc: any): DomainHandlerDeps { + const deps: any = { + resolveService: async (_c: any, name: string) => + name === SHARE_LINK_SERVICE ? svc : name === 'objectql' ? engine : undefined, + getRequestKernelService: async (_c: any, name: string) => (name === 'objectql' ? engine : undefined), + success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, ...(meta ? { meta } : {}) } }), + error: (message: string, httpStatus = 500, details?: any) => apiErrorResponse({ message, httpStatus, details }), + routeNotFound: (route: string) => apiErrorResponse({ message: `Route not found: ${route}`, httpStatus: 404 }), + errorFromThrown: realErrorFromThrown, + }; + return deps as DomainHandlerDeps; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { + await engines.pop()!.destroy(); + } catch { + /* noop */ + } + } +}); + +type Answer = { status: number; body?: any; headers?: Record }; +type Route = 'resolve' | 'messages'; + +async function harness(password: string) { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, + true, + ); + await engine.init(); + for (const o of [SysShareLink, CONVERSATIONS, MESSAGES]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); + await engine.syncSchemas(); + const sys = { context: { isSystem: true } } as any; + await engine.insert('ai_conversations', { id: 'conv_1', title: 'Chat' }, sys); + await engine.insert( + 'ai_messages', + { id: 'msg_1', conversation_id: 'conv_1', content: 'hello', created_at: '2026-01-01T00:00:00Z' }, + sys, + ); + const svc = new ShareLinkService({ engine: engine as any }); + const deps = makeDeps(engine, svc); + const link = await svc.createLink( + { object: 'ai_conversations', recordId: 'conv_1', password }, + { isSystem: true, userId: 'usr_creator' } as any, + ); + /** A public GET, with the request headers on `context.request` as the dispatcher plugin passes them. */ + const call = async ( + route: Route, + headers: Record | Headers = {}, + query: Record = {}, + ): Promise => { + const res = await handleShareLinksRequest(deps, `/${link.token}/${route}`, 'GET', undefined, query, { + request: { headers }, + } as unknown as HttpProtocolContext); + if (!res.handled || !res.response) throw new Error(`GET /${route} was not handled`); + return res.response as Answer; + }; + return { call, svc }; +} + +function expectServed(route: Route, answer: Answer, label: string) { + expect(answer.status, label).toBe(200); + expect(answer.body?.success, label).toBe(true); + if (route === 'messages') expect(answer.body?.data?.map((m: any) => m.id), label).toEqual(['msg_1']); + else expect(answer.body?.data?.record?.id, label).toBe('conv_1'); +} + +const UTF8 = { 'x-share-password-encoding': 'utf-8' }; + +describe('[#22049] dispatcher: X-Share-Password-Encoding: utf-8 carries any password', () => { + it.each([ + ['resolve', 'CJK', CJK], + ['resolve', 'emoji', EMOJI], + ['messages', 'CJK', CJK], + ['messages', 'emoji', EMOJI], + ] as const)('/%s serves a %s password sent percent-encoded', async (route, label, password) => { + const { call } = await harness(password); + expectServed(route, await call(route, { 'x-share-password': encodeURIComponent(password), ...UTF8 }), label); + }); + + it('reads the pair from a Fetch `Headers` too, case-insensitively', async () => { + const { call } = await harness(EMOJI); + const headers = new Headers({ 'X-Share-Password': encodeURIComponent(EMOJI), 'X-Share-Password-Encoding': 'UTF-8' }); + expectServed('resolve', await call('resolve', headers), 'Headers'); + }); + + it.each([ + ['resolve', 401, 'WRONG_PASSWORD'], + ['messages', 404, 'NOT_FOUND'], + ] as const)('/%s does not guess: the encoded value with no encoding header is compared raw', async (route, status, code) => { + const { call } = await harness(CJK); + const answer = await call(route, { 'x-share-password': encodeURIComponent(CJK) }); + expect(answer.status).toBe(status); + expect(answer.body?.error?.code).toBe(code); + }); +}); + +describe('[#22049] dispatcher: without the encoding header the value is read raw, unchanged', () => { + it.each([ + ['resolve', 'Latin-1', LATIN1], + ['resolve', 'raw with %', RAW_PERCENT], + ['messages', 'Latin-1', LATIN1], + ['messages', 'raw with %', RAW_PERCENT], + ] as const)('/%s serves a %s password sent raw', async (route, label, password) => { + const { call } = await harness(password); + expectServed(route, await call(route, { 'x-share-password': password }), label); + }); +}); + +describe('[#22049] dispatcher: a declared encoding that does not hold is refused, never compared raw', () => { + 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 answers %s 400 VALIDATION_FAILED, before the token is looked up', async (route, _label, headers) => { + const { call, svc } = await harness(CJK); + const resolveToken = vi.spyOn(svc, 'resolveToken'); + const answer = await call(route, { ...headers }); + expect(answer.status).toBe(400); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('VALIDATION_FAILED'); + expect(answer.body?.error?.message).toContain('X-Share-Password'); + expect(JSON.stringify(answer.body)).not.toContain(headers['x-share-password']); + expect(answer.headers?.['Cache-Control']).toBe('no-store'); + expect(answer.headers?.Vary).toBe('X-Share-Password, X-Share-Password-Encoding'); + expect(resolveToken).not.toHaveBeenCalled(); + }); + + it.each(['resolve', 'messages'] as const)( + '/%s: an undecodable value that IS the raw password is still refused once utf-8 is declared', + async (route) => { + const rawPassword = '%E5%88 22049'; + const { call } = await harness(rawPassword); + expectServed(route, await call(route, { 'x-share-password': rawPassword }), 'the raw form, undeclared'); + const declared = await call(route, { '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 { call } = await harness(CJK); + const answer = await call(route, { 'x-share-password': '%FF', 'x-share-password-encoding': 'latin1' }, { password: CJK }); + expectServed(route, answer, 'query wins'); + }); +}); diff --git a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts index 5b9ac80d276..59c57f8b0df 100644 --- a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts +++ b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts @@ -2,8 +2,10 @@ /** * [#21839] The dispatcher's two PUBLIC share-link routes answer with - * `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, and - * the authenticated routes are not touched. + * `Cache-Control: no-store` and `Vary: X-Share-Password, X-Share-Password-Encoding` + * on every outcome (the second name since #22049, which made the answer depend on + * the header declaring the password's encoding too), and the authenticated + * routes are not touched. * * Driven over a REAL `ObjectQL` + `@objectstack/driver-sql` + better-sqlite3, * the same harness `share-links-internal-hash-probe.test.ts` uses, so every @@ -101,7 +103,7 @@ async function harness() { function expectNoStore(answer: Answer, label: string) { expect(answer.headers?.['Cache-Control'], label).toBe('no-store'); - expect(answer.headers?.Vary, label).toBe('X-Share-Password'); + expect(answer.headers?.Vary, label).toBe('X-Share-Password, X-Share-Password-Encoding'); } describe('[#21839] dispatcher public share-link routes are never cached', () => { diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts index 74298831652..a3409536077 100644 --- a/packages/runtime/src/domains/share-links.ts +++ b/packages/runtime/src/domains/share-links.ts @@ -54,6 +54,13 @@ import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; import { isPublicSharingEnabled } from '@objectstack/spec/data'; // [#21197] The one dereference of an `internal` column — see the probe below. import { readInternalColumn } from '@objectstack/objectql/core'; +// [#22049] The one reading of the password header pair (`X-Share-Password` and +// the `X-Share-Password-Encoding` that declares it), shared with the +// plugin-sharing mount's `presentedPassword`. It lives in `@objectstack/types` +// because `@objectstack/plugin-sharing` is a DEV dependency here — the same +// reason `isPublicSharingEnabled` above is imported from the spec — and both +// packages already depend on `@objectstack/types`, so the import adds no edge. +import { readSharePasswordHeader, SHARE_PASSWORD_VARY, type SharePasswordHeaderReading } from '@objectstack/types'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -71,14 +78,16 @@ export function createShareLinksDomain(deps: DomainHandlerDeps): DomainRoute { * [#21839] Response headers the two public routes (`/:token/resolve` and * `/:token/messages`) answer with, on every outcome — success, refusal, or a * thrown error. `Cache-Control: no-store` keeps the token-released record (and - * any refusal) out of every browser and shared cache; `Vary: X-Share-Password` - * marks the answer as depending on that request header for any cache that does - * not honour `no-store`. The plugin-sharing mount - * (`plugin-sharing/src/share-link-routes.ts`) sends the same pair. + * any refusal) out of every browser and shared cache; `Vary: X-Share-Password, + * X-Share-Password-Encoding` marks the answer as depending on that request + * header, and on the one declaring its encoding (#22049), for any cache that + * does not honour `no-store`. The plugin-sharing mount + * (`plugin-sharing/src/share-link-routes.ts`) sends the same pair, from the + * same `SHARE_PASSWORD_VARY` constant. */ const PUBLIC_RESPONSE_HEADERS: Readonly> = Object.freeze({ 'Cache-Control': 'no-store', - Vary: 'X-Share-Password', + Vary: SHARE_PASSWORD_VARY, }); function isPublicShareLinkRoute(subPath: string, method: string): boolean { @@ -167,6 +176,19 @@ async function handleShareLinksRequestBody( const v = typeof h.get === 'function' ? h.get(name) : (h[name] ?? h[name.toLowerCase()]); return Array.isArray(v) ? v[0] : (v ?? undefined); }; + // [#21839 → #22049] The password a holder presented, read the ONE way both + // public routes read it: the `?password=` query parameter first (a URL needs + // no declared encoding, and when it wins the header pair is not read), then + // the `X-Share-Password` header as `X-Share-Password-Encoding` declares it — + // raw when that is absent, exactly as before; percent-encoded UTF-8 under + // `utf-8`. A declared encoding that does not hold is a refusal the caller + // answers `400 VALIDATION_FAILED` before the token is looked up, ⛔ never a + // raw compare. The plugin-sharing twin (`presentedPassword`) reads the same + // pair through the same `readSharePasswordHeader`. + const presentedPassword = (): SharePasswordHeaderReading => + typeof query?.password === 'string' + ? { ok: true, password: query.password as string } + : readSharePasswordHeader(headerOf('x-share-password'), headerOf('x-share-password-encoding')); const sendErr = (status: number, code: string, msg: string): HttpDispatcherResult => ({ handled: true, response: deps.error(msg, status, { code }), @@ -210,8 +232,9 @@ async function handleShareLinksRequestBody( const token = decodeURIComponent(parts[0]); const signedInUserId = ec?.userId; const recipientEmail = typeof query?.email === 'string' ? query.email : undefined; - const providedPassword = - typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password'); + const presented = presentedPassword(); + if (!presented.ok) return sendErr(400, 'VALIDATION_FAILED', presented.message); + const providedPassword = presented.password; const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword }); if (!resolved) { @@ -307,9 +330,9 @@ async function handleShareLinksRequestBody( // ── PUBLIC: ai_conversations messages for a resolved token ──── if (parts.length === 2 && parts[1] === 'messages' && m === 'GET') { const token = decodeURIComponent(parts[0]); - const providedPassword = - typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password'); - const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword }); + const presented = presentedPassword(); + if (!presented.ok) return sendErr(400, 'VALIDATION_FAILED', presented.message); + const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword: presented.password }); if (!resolved) return sendErr(404, 'NOT_FOUND', 'Share link not found'); if (resolved.link.object_name !== 'ai_conversations') { return sendErr(400, 'UNSUPPORTED', 'This share link does not expose messages'); From 7c729e17deee8493c43bc43a2ce9bb5027d0d9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:50:14 +0000 Subject: [PATCH 3/5] test(sharing): pin a raw password whose %-escape decodes cleanly, so a guessing reader cannot pass Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../plugin-sharing/src/share-link-password.test.ts | 9 ++++++--- .../src/domains/share-links-password-encoding.test.ts | 9 ++++++--- packages/types/src/share-password-header.test.ts | 9 ++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-password.test.ts b/packages/plugins/plugin-sharing/src/share-link-password.test.ts index bde92e43413..ddf966ed25d 100644 --- a/packages/plugins/plugin-sharing/src/share-link-password.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-password.test.ts @@ -488,7 +488,8 @@ describe('[#22049] the password header declares its encoding', () => { const CJK = '分享密码二二零四九'; const EMOJI = 'open 🔐🦊 sesame'; const LATIN1 = 'Déjà vu ½ ÿ 22049'; - const RAW_PERCENT = '100%25 sure %zz %'; + 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) { @@ -536,9 +537,11 @@ describe('[#22049] the password header declares its encoding', () => { it.each([ ['resolve', 'Latin-1', LATIN1], - ['resolve', 'raw with %', RAW_PERCENT], + ['resolve', 'raw with a stray %', RAW_PERCENT], + ['resolve', 'raw with a %-escape', RAW_PERCENT_ESCAPE], ['messages', 'Latin-1', LATIN1], - ['messages', 'raw with %', RAW_PERCENT], + ['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}`, { diff --git a/packages/runtime/src/domains/share-links-password-encoding.test.ts b/packages/runtime/src/domains/share-links-password-encoding.test.ts index bca13c87cf7..2c32090d2de 100644 --- a/packages/runtime/src/domains/share-links-password-encoding.test.ts +++ b/packages/runtime/src/domains/share-links-password-encoding.test.ts @@ -33,7 +33,8 @@ import { handleShareLinksRequest } from './share-links.js'; const CJK = '分享密码二二零四九'; const EMOJI = 'open 🔐🦊 sesame'; const LATIN1 = 'Déjà vu ½ ÿ 22049'; -const RAW_PERCENT = '100%25 sure %zz %'; +const RAW_PERCENT = 'grade 100% %zz 22049'; +const RAW_PERCENT_ESCAPE = '50%25 off 22049'; const CONVERSATIONS = { name: 'ai_conversations', @@ -167,9 +168,11 @@ describe('[#22049] dispatcher: X-Share-Password-Encoding: utf-8 carries any pass describe('[#22049] dispatcher: without the encoding header the value is read raw, unchanged', () => { it.each([ ['resolve', 'Latin-1', LATIN1], - ['resolve', 'raw with %', RAW_PERCENT], + ['resolve', 'raw with a stray %', RAW_PERCENT], + ['resolve', 'raw with a %-escape', RAW_PERCENT_ESCAPE], ['messages', 'Latin-1', LATIN1], - ['messages', 'raw with %', RAW_PERCENT], + ['messages', 'raw with a stray %', RAW_PERCENT], + ['messages', 'raw with a %-escape', RAW_PERCENT_ESCAPE], ] as const)('/%s serves a %s password sent raw', async (route, label, password) => { const { call } = await harness(password); expectServed(route, await call(route, { 'x-share-password': password }), label); diff --git a/packages/types/src/share-password-header.test.ts b/packages/types/src/share-password-header.test.ts index 6be7a9135f6..52acba8fda9 100644 --- a/packages/types/src/share-password-header.test.ts +++ b/packages/types/src/share-password-header.test.ts @@ -26,7 +26,8 @@ import { const CJK = '分享密码二二零四九'; const EMOJI = 'open 🔐🦊 sesame'; const LATIN1 = 'Déjà vu ½ ÿ'; -const RAW_PERCENT = '100%25 sure %zz %'; +const RAW_PERCENT = 'grade 100% %zz 22049'; +const RAW_PERCENT_ESCAPE = '50%25 off 22049'; describe('[#22049] the header names', () => { it('names the password header, its companion and the Vary value both doors send', () => { @@ -41,7 +42,8 @@ describe('[#22049] without the encoding header the value is read raw, unchanged' it.each([ ['ASCII', 'correct horse battery'], ['Latin-1', LATIN1], - ['raw with %', RAW_PERCENT], + ['raw with a stray %', RAW_PERCENT], + ['raw with a %-escape', RAW_PERCENT_ESCAPE], ['raw that looks percent-encoded', '%E5%88%86'], ['raw that looks RFC 8187-prefixed', "UTF-8''%E5%88%86"], ['empty', ''], @@ -62,7 +64,8 @@ describe('[#22049] X-Share-Password-Encoding: utf-8 decodes percent-encoded UTF- ['CJK', CJK], ['emoji', EMOJI], ['Latin-1', LATIN1], - ['raw with %', RAW_PERCENT], + ['raw with a stray %', RAW_PERCENT], + ['raw with a %-escape', RAW_PERCENT_ESCAPE], ['space-edged', ' spaced out '], ['ASCII', 'correct horse battery'], ])('%s round-trips through encodeURIComponent', (_label, password) => { From f7cabbb83a43dee23907ce06dbf9a727d39f29aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:59:41 +0000 Subject: [PATCH 4/5] docs(sharing): minor changeset for the declared password-header encoding; checklist Vary clause re-pointed Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../22049-share-password-header-encoding.md | 17 +++++++++++++++++ .../areas/access-security.json | 15 +++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/22049-share-password-header-encoding.md diff --git a/.changeset/22049-share-password-header-encoding.md b/.changeset/22049-share-password-header-encoding.md new file mode 100644 index 00000000000..2fe2fc96321 --- /dev/null +++ b/.changeset/22049-share-password-header-encoding.md @@ -0,0 +1,17 @@ +--- +"@objectstack/types": minor +"@objectstack/plugin-sharing": minor +"@objectstack/runtime": minor +"@objectstack/plugin-hono-server": 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. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 4c4aae4b63c..f483a0c2e42 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -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": [ @@ -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" } ], @@ -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" @@ -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" } ] }, From 7e8d722037fa3f20bb056dffcd44673cda892163 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:48:36 +0000 Subject: [PATCH 5/5] docs(changeset): name @objectstack/hono, which spreads the widened DEFAULT_CORS_ALLOW_HEADERS Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .changeset/22049-share-password-header-encoding.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/22049-share-password-header-encoding.md b/.changeset/22049-share-password-header-encoding.md index 2fe2fc96321..bd58704fb11 100644 --- a/.changeset/22049-share-password-header-encoding.md +++ b/.changeset/22049-share-password-header-encoding.md @@ -3,6 +3,7 @@ "@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`)