Skip to content

Commit a72b827

Browse files
committed
docs(qa): checklist items for the 17.7 pre-release security fixes
Adds three items and extends two for the rules the 17.7 pre-release follow-up landed: - platform-core.settings-audit-secret-fingerprint (new): the settings audit trail fingerprints a secret-valued setting with the keyed digest or not at all, never the value or an unkeyed hash. - identity-auth.implicit-account-linking-ownership (new): no implicit link to an unverified local user, an unlink is honoured, the explicit signed-in link still works, the platform IdP exception holds only on its OAuth path. - search.global-search-skips-unreadable (new): global search skips unreadable objects and fields instead of failing; row scope still narrows; a term only in a hidden field yields no hit. - access-security.share-link-capability-tokens rev 5: the stored hash never leaves, the X-Share-Password header, no-store on public answers. - integration-system.datasource-credential-refusal-matrix rev 3: A2/A7 recorded as a known environment gap; the unknown-driver clause states the ruled plugin-driver boundary. Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dce635 commit a72b827

5 files changed

Lines changed: 376 additions & 15 deletions

File tree

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,10 +1510,10 @@
15101510
},
15111511
{
15121512
"id": "access-security.share-link-capability-tokens",
1513-
"title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking",
1513+
"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",
15141514
"since": "v16",
15151515
"status": "active",
1516-
"revision": 4,
1516+
"revision": 5,
15171517
"priority": "P2",
15181518
"surface": "api",
15191519
"personas": [
@@ -1541,7 +1541,10 @@
15411541
"mint audience:'signed_in'; anon resolve → 401 SIGN_IN_REQUIRED; mint audience:'email' and resolve with an email OFF the allowlist → refused",
15421542
"DELETE /api/v1/share-links/<idOrToken> (revoke); anon resolve → 410 EXPIRED_OR_REVOKED; mint a short-expiry link and, after it expires, resolve → 410 — the record must never appear",
15431543
"delete the shared record r, then anon resolve the still-live (un-passworded, link_only) token → 404 INVALID_OR_EXPIRED, byte-identical to the answer for an unknown token (fail-closed, #5190: \"that record is gone\" is itself information) — capture it beside an unknown-token resolve for the byte comparison",
1544-
"GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links"
1544+
"GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links",
1545+
"the stored hash never leaves: mint a password-gated link and capture the MINT response, the LIST (GET /api/v1/share-links?object=F&recordId=r as the minter) and a SUCCESSFUL resolve. Search every body for the key password_hash and for any hash-shaped value",
1546+
"the password travels in a header: resolve the password-gated link with the password ONLY in the X-Share-Password request header (no ?password=). Then send a wrong value in the header, then the right password as ?password= (the compatibility form). Send a CORS preflight for the resolve route from another origin, with Access-Control-Request-Headers: x-share-password",
1547+
"public answers are never cached: on every public resolve outcome above (200; 401 NEEDS_PASSWORD and WRONG_PASSWORD; 404 for an unknown token; 410 after revoke) and on GET /api/v1/share-links/<token>/messages (a refusal on open-framework showcase, see knownGaps; the refusal is the answer being scored here), read the response HEADERS. Then read the headers of the authenticated create, list and revoke responses"
15451548
],
15461549
"acceptance": [
15471550
{
@@ -1579,11 +1582,32 @@
15791582
"oracle": "api",
15801583
"verify": "minter's list contains the token; the second member's list for the same object/recordId excludes it (share-links.ts createdBy pin)",
15811584
"evidence": "both list bodies"
1585+
},
1586+
{
1587+
"clause": "the stored password hash never leaves the server: no exit (the mint response, the list, the redemption result) carries password_hash or any form of the stored hash",
1588+
"oracle": "api",
1589+
"verify": "none of the captured bodies contains the key password_hash or a hash-shaped value of it (share-link-service.ts withoutPasswordHash is the one exit projection). A client reads a link's password state from the resolve route's NEEDS_PASSWORD answer, as before",
1590+
"evidence": "the mint, list and resolve bodies, searched in full"
1591+
},
1592+
{
1593+
"clause": "the password is accepted from the X-Share-Password request header, the preferred form because a header stays out of the request URL: header-only with the right password answers 200 and a wrong header value answers 401 WRONG_PASSWORD. The ?password= query form is still accepted. A cross-origin preflight allows the header by default",
1594+
"oracle": "api",
1595+
"verify": "the three resolve traces (header right → 200, header wrong → 401 WRONG_PASSWORD, query right → 200) and the preflight's Access-Control-Allow-Headers naming x-share-password (DEFAULT_CORS_ALLOW_HEADERS in plugin-hono-server; a deployment passing its own allowHeaders must add it itself)",
1596+
"evidence": "the three traces and the preflight response headers"
1597+
},
1598+
{
1599+
"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",
1600+
"oracle": "api",
1601+
"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",
1602+
"evidence": "the header block of every public response and of the authenticated list response"
15821603
}
15831604
],
15841605
"negative": [
15851606
"a resolve that returns the record after revoke/expiry, that includes a redactField, or that leaks another user's tokens in the list, is a FAIL",
1586-
"the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase"
1607+
"the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase",
1608+
"password_hash, or any stored form of the share-link password, in a mint, list or resolve body is a FAIL",
1609+
"a header-only correct password answering 401 NEEDS_PASSWORD is a FAIL: the route ignored the header, so a client is pushed back to putting the password in the URL",
1610+
"a public share-link response without Cache-Control: no-store, on ANY outcome including a refusal, is a FAIL. A cached answer can serve a password-released record to the next person on a shared browser or proxy"
15871611
],
15881612
"variants": [
15891613
"audience link_only",
@@ -1593,7 +1617,9 @@
15931617
"redactFields stripped",
15941618
"revoked",
15951619
"expired",
1596-
"record-gone (fail-closed)"
1620+
"record-gone (fail-closed)",
1621+
"password in the X-Share-Password header",
1622+
"password in ?password= (compatibility)"
15971623
],
15981624
"traps": [
15991625
"wrong-persona"
@@ -1604,7 +1630,14 @@
16041630
"packages/runtime/src/route-ledger.ts (share-links rows incl. public resolve/messages)",
16051631
"packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts",
16061632
"ADR-0047, ADR-0111 D8, #5190",
1607-
"cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)"
1633+
"cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)",
1634+
"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)",
1635+
"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)",
1636+
"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)",
1637+
"packages/plugins/plugin-hono-server/src/adapter.ts#DEFAULT_CORS_ALLOW_HEADERS (X-Share-Password in the default preflight allow-list)",
1638+
"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)",
1639+
"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')",
1640+
"content/docs/protocol/kernel/http-protocol.mdx (X-Share-Password in the allowed request headers) · #21839 · PR #21890"
16081641
],
16091642
"history": [
16101643
{
@@ -1630,6 +1663,12 @@
16301663
"date": "2026-10-04",
16311664
"change": "A5 record-gone leg and step 7 re-pointed to the measured answer: a deleted record's still-live link resolves 404 INVALID_OR_EXPIRED, byte-identical to an unknown token, never 410 RECORD_GONE (resolveToken's #5190 fail-closed record probe — share-link-service.ts loadRecordForServing — returns null, and the route's row probe in share-links.ts falls through to its one generic refusal, invalidOrExpired). Revoke/expiry legs unchanged (410 EXPIRED_OR_REVOKED). Stale clause found by the 17.7 pre-release runs",
16321665
"ref": "#21735"
1666+
},
1667+
{
1668+
"revision": 5,
1669+
"date": "2026-10-06",
1670+
"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",
1671+
"ref": "#21932"
16331672
}
16341673
]
16351674
},

0 commit comments

Comments
 (0)