Repository navigation
[feature] public share links on a separate origin - #7
Merged
Merged
Conversation
Documents can be published as read-only links that need no sign-in, in the shape of Claude.ai artifact sharing: create a link, copy it, revoke it. Why a separate origin rather than a new path Documents are stored author HTML and are written verbatim into the response body — there is no CSP, no sandbox and no sanitiser anywhere in the app, by design (README's trust model: members are trusted authors, and documents are allowed their own CSS/JS/mermaid). That containment holds only because just members can reach them. A path prefix is not a security boundary; the browser's boundary is the origin. On pensieve.pathors.com a shared document's script would be same-origin with the app, so a logged-in visitor's cookie would ride along on any fetch it makes to /api/* — readable workspace content, and POST /api/sources can delete every document a sync owns. So shares live on share.pensieve.pathors.com, and middleware.ts keeps the two from overlapping: /s/* serves only there, and that host serves only /s/*. The document is additionally framed with sandbox="allow-scripts" and no allow-same-origin, so it renders in an opaque origin. The two boundaries are independent; adding allow-same-origin silently removes the second one. Scoping - Assets: the asset table is only org-scoped, so a naive share would expose the whole workspace's asset tree. Referenced assets are derived from the shared document's own HTML per request — no schema change, stays correct as the document changes. - Backlinks: the workspace nav is not injected. It fetches the member-gated /api/graph (which would 403 into a silent no-op) and its backlinks would leak the titles of unshared documents. - Links: root-relative asset refs get the share prefix; root-relative document links are defused rather than pointed at the authenticated namespace. - Caching: no-store on the document, so revocation is not outlived by a cached copy; assets private, max-age=60. - Misses all return the same 404 shape, so probing cannot separate unknown from expired from revoked. The share row's id is the capability, so revoking deletes the row and re-sharing mints a fresh token — old links die. document_path is deliberately not a foreign key; both share routes join to document and 404 on an orphan.
❌ SonarQube Quality Gate ERROR — pathorsAI_pensieve
1 open issue on this PR:
|
Contributor
Author
|
@yui 這個 PR 掛 auto-merge 時因為 repo 沒有必要審核/blocking check,當場就 merge 了,沒有經過你 review。內容已經在 main,但還沒部署(worker 最後部署是 8/18,Workers Builds 尚未接上),所以還來得及在上線前看。 兩個想請你特別確認的點:
另外順帶回報一個不在本次範圍的既有問題: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Publishes a document as a read-only link that needs no sign-in — the shape of Claude.ai artifact sharing: create, copy, revoke. Refs #1.
Why a separate origin, not just a new path
Documents are stored author HTML written verbatim into the response body. There is no CSP, no sandbox and no sanitiser anywhere in the app — deliberately, per the README trust model: members are trusted authors, and documents are allowed their own CSS/JS/mermaid. That containment holds only because nobody but a member can reach them.
A path prefix is not a security boundary. The browser's boundary is the origin.
flowchart LR subgraph A["pensieve.pathors.com — session cookie"] APP["/o/** · /api/**"] end subgraph B["share.pensieve.pathors.com — no cookie"] SH["/s/:token"] --> IF["iframe: opaque origin<br/>sandbox=allow-scripts"] end IF -. "blocked: cross-origin + opaque" .-x APPOn the app origin, a shared document's script is same-origin with the app, so a logged-in visitor's cookie rides along on anything it fetches:
/api/searchand/api/graphreturn workspace content, andPOST /api/sources {org, deleteId}deletes every document a sync owns. HttpOnly does not help — the script never needs to read the cookie, only to have the browser send it.Two independent boundaries:
middleware.ts(new — the repo had none) keeps the origins disjoint:/s/*serves only onSHARE_HOST, and that host serves only/s/*.sandbox="allow-scripts"and noallow-same-origin, so it renders in an opaque origin.Scoping decisions
assetis only org-scoped — a naive share would expose the whole workspace's asset tree/api/graph(403 → silent no-op) and its backlinks leak titles of unshared documents/o/${slug}/d— visitors would land in the authenticated namespace and 403no-storeon the document,private, max-age=60on assetsSchema
One additive table. Already applied to production (hand-written DDL in a single transaction,
ON_ERROR_STOP=1) — verified: 7 columns, both FKs,share_org_docUNIQUE. Constraint names match whatdrizzle-kit generateproduces, so this table is a genuine no-op for any future diff.The row id is the capability, so revoking deletes the row and re-sharing mints a fresh token — old links die immediately.
document_pathis deliberately not a composite FK todocument(organization_id, path):lib/github.tsprunes every document a source owns when a sync yields zero files, andON DELETE CASCADEwould take all of that workspace's share links with it. Both share routes join todocumentand 404 on an orphan instead. Worth revisiting once that prune is fixed.visibilityships aslinkonly;authenticatedandorgare reserved. Note the app has no role checks anywhere today (member.roleis stored but never consulted), so any member can share — consistent with the rest, flagged in the route if publishing should be owner-only.Infra
share.pensieve.pathors.comis already bound to thepensieveworker (zonepathors.com); DNS resolves and TLS is issued.SHARE_HOSTis a plaintextvarinwrangler.jsonc, not a secret, so it ships with the deploy. Until this merges and deploys, that host serves the old worker — harmless (cookies are host-only and the OAuth redirect is pinned to the app origin, so nothing authenticates there), but share URLs 404 until then.SHARE_HOSTunset disables the host split, so local dev still works onlocalhost:3000.Verification
tsc --noEmitclean;next buildclean, with/s/[token],/s/[token]/d/[...path],/api/sharesand Middleware all registered. Not yet exercised end-to-end against prod — that needs this deployed.