You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f5b8e29
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-sharing): share-link password never leaves the server, is stored with the platform slow hash, and is accepted in a header (#21890)
Fixes#21839
Clause-②: no
Server half of the share-link password card. The console transport
(sending the password in a header) is the separate objectui card; this
PR keeps the query parameter working so the current console is
unaffected until that lands.
## What changed
All in `packages/plugins/plugin-sharing/src`, plus one changeset and a
regenerated census page.
1. **The stored hash never leaves the server.**
`ShareLinkService.createLink` returned the row it had just inserted,
hash included, and both mounts of `POST /api/v1/share-links` (this
plugin's route and the runtime dispatcher twin) answer with that return
value. It now returns the row through one exit projection,
`withoutPasswordHash`. `listLinks` and `resolveToken` pass their results
through the same projection. They already came from engine reads that
strip the `internal` column, so the projection holds whichever engine is
wired. The audit ledger and engine write responses already omit
`internal` fields, so this PR does not change them.
2. **The stored form is the platform's slow password hash.** New module
`share-link-password.ts`: scrypt with the parameters account passwords
use (N=16384, r=16, p=1, 64-byte key, 16-byte salt as hex, NFKC). It is
built on `node:crypto` with no new dependency, and new rows are stored
as `scrypt$SALT$KEY`. The two legacy forms (`sha256$…` and the `weak$…`
no-SubtleCrypto fallback) still verify. `resolveToken` re-hashes a
legacy row into the current form after a successful verification, but
only once every later gate (standing policy, record existence,
eligibility) has passed. A switched-off or ineligible link therefore
takes no write. Every comparison uses `timingSafeEqual`, and the
plaintext legacy form is compared through a digest of both sides. A
refused upgrade write does not block the read, because the legacy form
still verifies. It is reported once per instance at `error`, naming the
link only. A deployment that injects its own
`hashPassword`/`verifyPassword` pair is left alone. The old default
could also write a `weak$` row on a runtime without SubtleCrypto; it no
longer can.
3. **The password travels in a header.** One helper,
`presentedPassword`, now reads the password for both public routes.
`/:token/resolve` already accepted `x-share-password`.
`/:token/messages` on this mount read only `?password=` and now accepts
the header too, as its runtime twin always has. The query parameter is
still accepted for compatibility (named in the changeset). Neither form
is logged on this path: the routes write no log line, the service's log
lines name the link and never the presented password, and the Hono
adapter's failure log records the path without the query string.
## Tests
New `src/share-link-password.test.ts`, 19 cases on a real `ObjectQL` +
`driver-sql` + better-sqlite3, so the engine's strip is live:
- No exit carries the `password_hash` key or any piece of the stored
hash: mint (service and `POST` route), list (service and route),
redemption (service and route).
- Stored form matches `scrypt$` + 32 hex + `$` + 128 hex, holds no
plaintext, and is salted per row. The verifier refuses wrong, empty and
unknown-form inputs.
- For both legacy forms (salted SHA-256, plaintext): still verifies; a
wrong or missing password is refused and writes nothing; the right
password upgrades the row to `scrypt$`; the upgraded hash verifies the
same password and still refuses a wrong one.
- No upgrade on a switched-off object. No upgrade with an injected
hasher pair. A refused upgrade still serves the read, leaves the legacy
form in place, is reported exactly once with `{ link, reason }`, and no
log carries the password or the hash.
- The list and the redemption result carry no hash even from an engine
that does not strip it (a control asserts that engine does return it),
so the service's own projection is pinned.
- The header is accepted on `/resolve` and `/messages`, and so is
`?password=`. A wrong password is refused for both forms on both routes,
with ADR-0112 `code` + `success: false` asserted. No log line carries
the presented password on any outcome.
Local runs, final head `eacae6b6be` unless noted (`eacae6b6be` adds the
non-stripping-engine pin to the test file):
- `pnpm --filter @objectstack/plugin-sharing test` at head `eacae6b6be`:
39 files / 973 tests passed.
- `pnpm --filter @objectstack/plugin-sharing typecheck` (src + scripts +
`check:test-typecheck`): green; the test layer holds the existing 2
files / 3 pinned signatures, with nothing new.
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2
src/domains/share-links` (the dispatcher twin's suites, against the
rebuilt plugin `dist/`): 2 files / 29 tests passed.
- Lint narrowed to the 4 changed `.ts` files: `eslint --no-inline-config
--format json` reports 4 files, 0 errors, 0 warnings. The population is
read from `eslint.config.mjs`: `**/*.{ts,…}` covers all four.
Invariance: the config uses no typed linting (no
`parserOptions.project`), so this diff cannot move any verdict on an
untouched file.
- `dispatch-gates --ran`: 95 of 97 derived families run, all exit 0. Two
are **NOT MEASURED**, both `PREREQUISITE NOT MET` (exit 3):
`check:dual-build-cjs-loads` needs a whole-repo build, and `check:i18n`
needs the CLI build closure. This diff changes no object, label or
translation source. Both are declared to CI.
Ablations (each mutation made with `scripts/ablation-replace.mjs`, which
confirmed the change on disk; restored to the HEAD blob each time,
confirmed by matching hashes and an empty `git diff HEAD`):
- the `withoutPasswordHash` projection removed: 2 failed / 17 passed
(the mint pin and the non-stripping-engine pin).
- legacy upgrade disabled: 3 failed / 15 passed (both legacy-row pins
and the refused-upgrade pin).
- header read removed from `presentedPassword`: 4 failed / 14 passed
(both header pins, the header wrong-password pin, the redemption-route
pin).
## Acceptance notes
- **Twin precedence.** When both forms arrive, the query parameter is
read first, on both mounts: the runtime dispatcher twin
(`packages/runtime/src/domains/share-links.ts`, outside this card's file
surface) reads it that way, and two mounts of the same routes must not
answer differently. The header is the form clients should send. Making
the header win on both mounts is a small follow-up for whoever retires
the query form. Carrier: the objectui console card, whose landing is the
point the query parameter can go.
- **Cost per protected redemption.** Verifying a protected link's
password now costs one slow-hash evaluation per request, by design and
at the same cost as a sign-in.
- **The spec contract type is unchanged.** `ShareLink.password_hash`
stays declared (optional) in
`packages/spec/src/contracts/share-link-service.ts`, as the
persisted-shape mirror. Only the runtime value leaving the service drops
it, so no published type narrows. See `Clause-②` above.
- **Census.** The upgrade write is one more engine write call site, so
`node scripts/tenant-audit-census.mjs --write` regenerated
`content/docs/permissions/tenant-audit-census.mdx` and its counts file.
The page's hand-written figures moved from 232 to 233 (decidable 154 to
155, elevated 113 to 114). `check:tenant-audit-census` and its self-test
are green.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Share-link passwords follow the platform's credential rules (#21839).
9
+
10
+
-**The stored hash never leaves the server.** The share-link mint response (`POST /api/v1/share-links`, and `ShareLinkService.createLink`'s return value) no longer carries `password_hash`. The list and the redemption result are projected the same way. A client that reads a link's password state keeps reading it from the redemption route's `NEEDS_PASSWORD` answer, as before.
11
+
-**The stored form is the platform's slow password hash.** New passwords are hashed with scrypt at the parameters account passwords use, instead of one salted SHA-256. Links minted before this release keep working: a stored password in a legacy form still verifies, and it is re-hashed into the new form on its first successful redemption. Every comparison is constant-time. A deployment that injects its own `hashPassword` / `verifyPassword` pair is unaffected, and its stored forms are left alone.
12
+
-**The password travels in a header.** Both public share-link routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) accept the `x-share-password` request header, the preferred form, because a header is not part of the request URL. The `?password=` query parameter is still accepted for compatibility, so current consoles keep working until they move to the header. `/messages` accepted only the query parameter on this mount before.
13
+
-**Cross-origin clients can send the header.**`X-Share-Password` 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` is unchanged; add the header to that list to let a cross-origin client use it.
14
+
-**Public share-link answers are not cached.** Both public routes answer with `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, on both mounts (the sharing plugin's routes and the runtime dispatcher's `/share-links` domain). The authenticated create, list and revoke routes are unchanged.
15
+
-**Hashing works in WebContainer.** On StackBlitz WebContainer, where `node:crypto.scrypt` is incomplete, the password is hashed with the pure-JS scrypt from `@noble/hashes` (now a dependency of `@objectstack/plugin-sharing`, as it already is of `@objectstack/plugin-auth`), at the same parameters and in the same stored form. A hash made on either runtime verifies on the other.
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+17-17Lines changed: 17 additions & 17 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122
122
123
123
The same holds twice over for the context. An options argument spelled as a
124
124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125
-
forwarding shim cannot, and **67 of the 232 sites are spelled that way**. A
125
+
forwarding shim cannot, and **67 of the 233 sites are spelled that way**. A
126
126
context resolved from an inline literal or a local `const` can be tested for
127
127
`isSystem`; one arriving from a helper call cannot.
128
128
@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187
187
188
188
| carried figure | where it survives | this census |
189
189
| :--- | :--- | ---: |
190
-
| 175 write call sites | quoted in the merged changeset |**232**|
190
+
| 175 write call sites | quoted in the merged changeset |**233**|
191
191
| 24 carrying no tenant context | quoted in the merged changeset |**9** provable and tenancy-enabled; **34** more whose options argument is unreadable |
192
-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**154 of 232** decidable, **78** undecidable |
193
-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 113 decidably elevated, 0 decidably not, 102 undecidable |
192
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**155 of 233** decidable, **78** undecidable |
193
+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 114 decidably elevated, 0 decidably not, 102 undecidable |
194
194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195
195
196
196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207
207
208
208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209
209
figure has no surviving corroboration anywhere in the tree.** This census reads
210
-
113 of 232 (49%) as decidably elevated, with 102 more whose elevation is a
210
+
114 of 233 (49%) as decidably elevated, with 102 more whose elevation is a
211
211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212
212
answer is that a static reading cannot settle it.
213
213
214
-
⇒ **Cite `9 / 232`, and say what it is**: the sites whose options argument was
214
+
⇒ **Cite `9 / 233`, and say what it is**: the sites whose options argument was
215
215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216
216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217
217
without tenant context" — **34 further sites** have an options argument this
@@ -223,28 +223,28 @@ cannot read, and they are neither in nor out.
223
223
224
224
| what | count |
225
225
| :--- | ---: |
226
-
| write call sites on the application surface |**232**|
227
-
| …whose object name is statically decidable |154|
226
+
| write call sites on the application surface |**233**|
227
+
| …whose object name is statically decidable |155|
228
228
| …whose object name is chosen at run time | 78 |
229
-
| …against an object with tenancy ENABLED |153|
229
+
| …against an object with tenancy ENABLED |154|
230
230
| …against an object that declares tenancy off | 1 |
231
-
| threading a tenant context |148|
231
+
| threading a tenant context |149|
232
232
| PROVABLY carrying none (options read, no context key) |**17**|
233
233
| …of those, against a decidably tenancy-enabled object |**9**|
234
234
| options argument UNREADABLE — may or may not carry one | 67 |
235
235
| …of those, against a decidably tenancy-enabled object | 34 |
236
-
| threading a decidably ELEVATED (`isSystem`) context |113|
236
+
| threading a decidably ELEVATED (`isSystem`) context |114|
237
237
| threading a context that is decidably NOT elevated | 0 |
238
238
| threading a context whose elevation is a run-time fact | 102 |
239
239
240
240
| how the instrument reached the site | count |
241
241
| :--- | ---: |
242
-
| receiver carried a readable engine type |184|
242
+
| receiver carried a readable engine type |185|
243
243
| receiver erased, placed by the object NAME | 28 |
244
244
| receiver erased, placed by an `object: string` PARAMETER | 15 |
245
245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246
246
247
-
| object name spelled inline |103|
247
+
| object name spelled inline |104|
248
248
| object name spelled through a `const`| 51 |
249
249
| object name is an `object: string` parameter | 17 |
250
250
| object name is some other run-time expression | 61 |
@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297
297
their values are not compared. The reasoning, and the measurement behind it,
Three of the allowed request headers are easy to overlook, and each one disables
1007
+
Four of the allowed request headers are easy to overlook, and each one disables
1008
1008
a feature if an intermediate proxy strips it:
1009
1009
1010
1010
| Header | Why it is allowed |
1011
1011
| --- | --- |
1012
1012
|`X-Tenant-ID` / `X-Environment-Id`| Route the request to its environment on a multi-tenant host. |
1013
1013
|`If-Match`| Carries the OCC token on record `PATCH`es. Without it, a cross-origin save fails in the browser with "Failed to fetch". |
1014
+
|`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. |
1014
1015
1015
1016
The two **exposed** response headers matter to browser clients specifically:
1016
1017
`set-auth-token` delivers a rotated session token (without it a cross-origin
0 commit comments