Skip to content

Commit 20293d6

Browse files
os-salesclaude
andauthored
fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites (#14905)
* fix(sharing): gate the share-link route probe on publicSharing.enabled at both sites The route-level probe above `resolveToken` answered from the token row with no knowledge of the object's standing `publicSharing.enabled` policy, so a real-but-switched-off link carrying a `password_hash` still drew `401 NEEDS_PASSWORD` / `WRONG_PASSWORD` and one with `audience: 'signed_in'` still drew `401 SIGN_IN_REQUIRED` — the existence oracle `share-link-service` states in prose that it closes, re-opened one layer up. Both probe sites read the policy before answering from the row, and every arm (the 410 included) falls through to the generic `404 INVALID_OR_EXPIRED` that unknown, revoked, expired and ineligible tokens already give. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * test(sharing): pin the gated probe at both sites, both shapes, byte-equal to unknown Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(sharing): changeset + re-anchor the system-context census page The census page's line anchors into `share-link-service.ts` moved by exactly the 20 lines this branch inserted above `getPolicy`; repaired with the gate's own `--fix`, which rewrote 5 anchors and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * test(sharing): declare the fixture's publicSharing block in the envelope conformance double The envelope conformance harness stubs `SharingEngine` without `getSchema`, so under the gated probe every one of its four row arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`, `EXPIRED_OR_REVOKED`) fell through to the generic 404 — correctly, and fail-closed, but the refusals whose ENVELOPE this module exists to pin were then unreachable. The double now declares the block for the object its probe rows name, and those rows name it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(runtime): cite the real test file in the share-link mirror docblock The mirror's docblock told the next reader that the two spellings of `isPublicSharingEnabled` are held equal by pins in `share-links-probe-policy-gate.test.ts`. `git ls-tree -r` has zero entries for that name anywhere in the repo — the pins are in `share-links-enforcement-context.test.ts`, in this same directory. This lands on the change's own thesis: a security property stated in a comment is worth having only if the next reader can follow the comment to the thing that holds it. A citation to a file that does not exist is the same defect class the gate itself closes, one layer up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * test(sharing,runtime): pin the 410 arm's EXPIRED half, at both sites The gated 410 arm is reached by two predicates, not one: row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()) Both sites pinned only the revoked half. A pin on that half alone leaves the expired half free to keep answering `410 EXPIRED_OR_REVOKED` on a switched-off object — the same existence oracle, reached by the other predicate, and invisible to every assertion in the file. Both new cases carry the same reverse check the siblings do (with the block ON an expired link is still 410) and the same `expectIndistinguishable` byte-equality assertion against the unknown-token answer. Expiry is stamped on the stored row rather than minted: `createLink` refuses a past `expiresAt` outright with `422 EXPIRY_IN_PAST`, so back-dating the row is the only way to reach an already-expired link — which is what the passage of time does to a live one, and the stamp the file's existing #13608 pins already use. The changeset's "Consumer impact" paragraph named only the password-prompt consequence. The 410 shift is equally consumer-visible — a different sentence in the objectui console, which branches on the refusal STATUS and never on the body's error code — so it is now named too, with the measured consumer and its line range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2fd3f1c commit 20293d6

8 files changed

Lines changed: 716 additions & 20 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
fix(sharing): gate the share-link route probe on `publicSharing.enabled`, at both probe sites (#14637)
7+
8+
**BREAKING** runtime behaviour change on a published HTTP path:
9+
`GET /api/v1/share-links/:token/resolve` answers `404 INVALID_OR_EXPIRED` where
10+
it used to answer `401 NEEDS_PASSWORD` / `401 WRONG_PASSWORD` /
11+
`401 SIGN_IN_REQUIRED` / `410 EXPIRED_OR_REVOKED`, for every link whose object
12+
has `publicSharing.enabled` switched off. Shipped as `minor` under the repo's
13+
launch-window convention (a breaking change does not burn a major while the
14+
stack is in lockstep). No published export is added, removed or re-shaped; the
15+
level carries the breaking banner, not a surface change.
16+
17+
#14033 made `publicSharing.enabled` a standing policy: `resolveToken()` re-reads
18+
the object's current block on every redemption and refuses a switched-off link
19+
with the same undifferentiated `null` a revoked, expired, unknown or ineligible
20+
token gets — because, in that gate's own words, for a caller who may hold
21+
nothing but a token a distinguishable "sharing is off for this object" is an
22+
**existence oracle**.
23+
24+
The HTTP layer above it then re-opened exactly that oracle. Both share-link
25+
surfaces run a row probe after `resolveToken()` returns null, to answer with a
26+
more useful status, and both answered from the `sys_share_link` row with no
27+
knowledge of the object's block. So an anonymous caller could still tell a
28+
real-but-switched-off token from an unknown one three ways: a row carrying
29+
`password_hash` drew `401 NEEDS_PASSWORD`, the same row with any password drew
30+
`401 WRONG_PASSWORD` — including a **correct** password, which is both an oracle
31+
and a lie, since that link can serve nothing — and a row with
32+
`audience: 'signed_in'` drew `401 SIGN_IN_REQUIRED`. A security property stated
33+
in one layer and defeated in the layer above it is worse than one never claimed,
34+
because the next reader believes the comment.
35+
36+
**What changed.** Both probes read the object's standing policy before they
37+
answer from the row, and when the block is off every arm falls through to the
38+
generic `404 INVALID_OR_EXPIRED` that unknown, revoked, expired and ineligible
39+
tokens already give — byte-for-byte the answer a token that never existed
40+
receives. The `410 EXPIRED_OR_REVOKED` arm is included: gating only the two 401
41+
arms would leave a third class of link answer and a rule about which arms are
42+
gated. An object whose schema the engine cannot answer for is `enabled: false`
43+
by `getPolicy`'s definition and is refused the same way — fail-closed, the same
44+
definition `createLink` and `resolveToken` already use.
45+
46+
The fix lands at **both** sites in one change, because the probe exists twice:
47+
`plugin-sharing`'s REST routes, and the `/share-links` dispatcher domain in
48+
`@objectstack/runtime` that is the designed primary surface for cloud's
49+
per-environment kernels (`registerShareLinkRoutes: false`). Fixing one would
50+
have moved the oracle to whichever embedding uses the other.
51+
52+
**Nothing else moves.** With the block ON, every refusal is exactly what it was:
53+
`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED` and `EXPIRED_OR_REVOKED`
54+
are unchanged in status, code and message, and a correct password or a signed-in
55+
viewer still resolves the record. Mint-time behaviour is untouched, no
56+
`sys_share_link` row is written or read differently, and no error code is added
57+
or retired.
58+
59+
**Consumer impact.** A viewer that branches on the refusal STATUS sees TWO
60+
changes, for links on a switched-off object only — and the measured consumer
61+
branches on status alone. On the objectui console at `67dadd6`,
62+
`apps/console/src/pages/SharedRecordPage.tsx` lines 70-85 dispatch on
63+
`res.status` and never on the body's error code, so:
64+
65+
- all three 401 arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`)
66+
rendered the password prompt and now render the 404 copy, "This link is
67+
invalid or no longer available.";
68+
- the 410 arm rendered "This link has expired or was revoked." and now renders
69+
that same 404 copy.
70+
71+
Both shifts are the intended outcome and were accepted with the ruling: a
72+
correct password on such a link yields nothing, so prompting for one teaches the
73+
holder to open a door that is bricked up, and "expired or revoked" is a claim
74+
about a token whose existence the caller must not be able to confirm. Links on
75+
objects whose block is on are unaffected — prompt, 410 copy and 200 render
76+
included.
77+
78+
Maintainer ruling 2026-09-03 (decision batch #17, item 1), verbatim 「同意」,
79+
adopting option A over option B (keep the 401 and document the accepted oracle)
80+
and option C (gate only the two 401 arms, rejected as proliferation).
81+
82+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, type, default and accept-set, and this change is only WHICH HTTP STATUS the route layer answers with while that switch is off. There is no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform on any consumer — a deployment that wants the 401 affordance back enables the object's block, which is an authoring decision, not a migration. -->

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The largest single consumer — **17 of the 106 sites**.
135135
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
138-
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
138+
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` |
139139
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
140140
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |
141141

0 commit comments

Comments
 (0)