Repository navigation
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
Conversation
… 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 <noreply@anthropic.com>
…-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 <noreply@anthropic.com>
…a guessing reader cannot pass Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ing; checklist Vary clause re-pointed Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d2d4b1282892116667fadea4819e17c0bec9b9ab && git checkout d2d4b1282892116667fadea4819e17c0bec9b9ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2015c540287ee136ec0226ec7e4fb9b22f8b1351 7e8d722037fa3f20bb056dffcd44673cda892163 && git checkout -B drift-repro 2015c540287ee136ec0226ec7e4fb9b22f8b1351 && git merge --no-ff 7e8d722037fa3f20bb056dffcd44673cda892163
node scripts/docs-audit/affected-docs.mjs --json 2015c540287ee136ec0226ec7e4fb9b22f8b1351
|
Contract reviewServed-tier: Inputs read: card #22049 (body and all four comments: triage ① Derived judgmentsEach accept-set or public-surface change the diff implies, with its judgment:
② Semver level
③ Boundary flagsDev flags (os-dev-report
Check-runs on the head, read 2026-10-07T05:42:01Z: 25 success, 2 skipped by paths filter ( Why FAIL: ② alone. The changeset must also name Implemented-by: VERDICT: FAIL Generated by Claude Code |
…FAULT_CORS_ALLOW_HEADERS Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review after the FAIL record What the new head changes, confirmed from the diff rather than from the dispatch note: exactly one commit on top of ① Derived judgmentsUnchanged from ② Semver level
③ Boundary flagsUnchanged from Check-runs on the new head, read 2026-10-07T05:53:54Z: 14 success, 2 skipped by paths filter ( Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22049
Clause-②: yes (widening)
Accepted-set change. New: a request to
GET /api/v1/share-links/:token/resolveor/:token/messagesmay carryX-Share-Password-Encoding: utf-8(compared case-insensitively) besideX-Share-Password. The password header is then read as the password's UTF-8 bytes, percent-encoded (whatencodeURIComponentproduces), 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-linksdomain. Unchanged: a request withoutX-Share-Password-Encodingis read as before, withX-Share-Passwordraw 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-Encodingwith any other value is refused. Before this change that header meant nothing and no client sent it. Also refused isutf-8over 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 answer400with codeVALIDATION_FAILEDin 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_FAILEDis already registered under both@objectstack/plugin-sharingand@objectstack/runtime, so no error code is added.What changed
@objectstack/typesexportsreadSharePasswordHeader(passwordHeader, encodingHeader)and the constantsSHARE_PASSWORD_HEADER,SHARE_PASSWORD_ENCODING_HEADER,SHARE_PASSWORD_ENCODING_UTF8andSHARE_PASSWORD_VARY. The sharing plugin'spresentedPasswordand the runtime domain'spresentedPasswordboth read the header pair through it. On a refusal, each door answers400 VALIDATION_FAILEDin 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.Vary: X-Share-Password, X-Share-Password-Encoding, still besideCache-Control: no-store.DEFAULT_CORS_ALLOW_HEADERS(@objectstack/plugin-hono-server, also applied by@objectstack/hono) gainsX-Share-Password-Encoding.X-Share-Password-Encodingrow incontent/docs/protocol/kernel/http-protocol.mdx(CORS block and table). The platform checklist itemaccess-security.share-link-capability-tokensmoves to revision 6, because its Vary clause would otherwise read false.minorchangeset fortypes,plugin-sharing,runtimeandplugin-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 inVary. A deployment that passes its ownallowHeadersmust addX-Share-Password-EncodingbesideX-Share-Passwordbefore a cross-origin client can send an encoded password. The changeset says so.Landing outside the expected file surface, and why
packages/typesis the helper home. It is the readers' existing common import.@objectstack/plugin-sharingis only a dev dependency of the runtime, so importing from the plugin would add a dependency edge, andpackages/specis 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 aTypeErroron a CJK value.' p w \t'as'p w'.ShareDialogsendspassword.trim(). The landing page sends the typed value, and the browser'sHeadersstrips its leading and trailing HTTP whitespace.createLinkhashes the input as is,resolveTokenverifies it as is, and the new reading does not trim.utf-8, a space-edged password now survives transport as%20.Verification (head
f7cabbb83a)/resolveand/messagesserve a CJK and an emoji password sent percent-encoded underutf-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%25escape each resolve sent raw. A truncated UTF-8 sequence,%FF, and an unknown encoding each get400 VALIDATION_FAILED, withsuccessfalse, the public headers present, andresolveTokennever called. A value that IS the raw password but does not decode underutf-8is still refused.?password=still wins.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), andhono-plugin.test.ts(the new CORS pin).types24 files / 739 passed;plugin-sharing40 / 1002;runtime(--project local) 332 / 4693 passed, 19 skipped;plugin-hono-server27 / 326.typecheckis green on all four, including each package'scheck:test-typecheck.scripts/ablation-replace.mjs(anchor hit and blob change proven, restore proven against the HEAD blob), with@objectstack/typesrebuilt andscripts/ablation-dist-preflight.mjsproving the marker indist/, then absent after the restore leg. Every leg turned red as predicted and went back to green:pnpm dev -- --fresh, random port, torn down). Four links were minted overPOST /api/v1/share-links.utf-8/UTF-8, a raw Latin-1 byte (cafplus byte E9), and a raw50%25 off.401 WRONG_PASSWORD.%E5%88underutf-8, thelatin1encoding, and%FFon/messageseach get400 VALIDATION_FAILED./messageswith the CJK password got past the password to400 UNSUPPORTED, because the record is not a conversation.Access-Control-Allow-Headersnaming both headers.node scripts/pm/dispatch-gates.mjsre-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 afterturbo run buildthey exit 0.--ranreconciliation: 97 derived, 97 run, 0 NOT-MEASURED, a derived zero. The share-link dogfood filesshare-links-self-list.dogfood.test.tsandshowcase-client-liaison-fixtures.dogfood.test.tspass (16 tests)..tsfiles are the whole population the**/*.{ts,…}globs ineslint.config.mjsselect from this diff; the other 3 files are.md,.mdxand.json. Run with--no-inline-config --format json: 10 files, 0 errors, 0 warnings. Type-aware linting is not enabled in that config (noparserOptions.project), so this diff cannot move any untouched file's verdict.origin/mainmoved 3 commits past the basea7a48b784d, and none of them touches these packages or files.Acceptance notes
ShareDialogmints. JStrim()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.?password=read is untouched here.Generated by Claude Code