|
1510 | 1510 | }, |
1511 | 1511 | { |
1512 | 1512 | "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", |
1514 | 1514 | "since": "v16", |
1515 | 1515 | "status": "active", |
1516 | | - "revision": 4, |
| 1516 | + "revision": 5, |
1517 | 1517 | "priority": "P2", |
1518 | 1518 | "surface": "api", |
1519 | 1519 | "personas": [ |
|
1541 | 1541 | "mint audience:'signed_in'; anon resolve → 401 SIGN_IN_REQUIRED; mint audience:'email' and resolve with an email OFF the allowlist → refused", |
1542 | 1542 | "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", |
1543 | 1543 | "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" |
1545 | 1548 | ], |
1546 | 1549 | "acceptance": [ |
1547 | 1550 | { |
|
1579 | 1582 | "oracle": "api", |
1580 | 1583 | "verify": "minter's list contains the token; the second member's list for the same object/recordId excludes it (share-links.ts createdBy pin)", |
1581 | 1584 | "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" |
1582 | 1603 | } |
1583 | 1604 | ], |
1584 | 1605 | "negative": [ |
1585 | 1606 | "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" |
1587 | 1611 | ], |
1588 | 1612 | "variants": [ |
1589 | 1613 | "audience link_only", |
|
1593 | 1617 | "redactFields stripped", |
1594 | 1618 | "revoked", |
1595 | 1619 | "expired", |
1596 | | - "record-gone (fail-closed)" |
| 1620 | + "record-gone (fail-closed)", |
| 1621 | + "password in the X-Share-Password header", |
| 1622 | + "password in ?password= (compatibility)" |
1597 | 1623 | ], |
1598 | 1624 | "traps": [ |
1599 | 1625 | "wrong-persona" |
|
1604 | 1630 | "packages/runtime/src/route-ledger.ts (share-links rows incl. public resolve/messages)", |
1605 | 1631 | "packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts", |
1606 | 1632 | "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" |
1608 | 1641 | ], |
1609 | 1642 | "history": [ |
1610 | 1643 | { |
|
1630 | 1663 | "date": "2026-10-04", |
1631 | 1664 | "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", |
1632 | 1665 | "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" |
1633 | 1672 | } |
1634 | 1673 | ] |
1635 | 1674 | }, |
|
0 commit comments