Task
A store's own page, served by the node at /s/<store>/, can make the node land attacker-chosen capsules as bondable — and it survives the two-axis fold that fixes the cross-site case (dig-node#436, PR #442).
This is not the cross-site variant. That one is fixed: chrome-extension:// and cross-site pages now fold to Peer and land Suppress. This is the same-origin variant, and it is more reachable, because the node's own CSP explicitly permits it.
The chain, each link verified in the tree
/s/*path and POST / are the SAME router on the SAME port — server.rs:255 (.route("/", get(health).post(rpc))) and server.rs:281 (.route("/s/*path", get(store_serve))), one Router::new() at :254, served on all three listeners at :2290/:2298/:2306.
- A store page therefore shares an origin with the RPC endpoint.
http://dig.local:<port>/s/<store>/index.html and http://dig.local:<port>/ differ only in path.
- The CSP permits the attacker's script to run and to call back.
STORE_CSP (content.rs:96-104) sets script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: and connect-src 'self' …. 'self' IS the RPC endpoint.
- The browser reports
Sec-Fetch-Site: same-origin → RequestProvenance::FirstParty (download.rs:397 maps only "cross-site" to CrossSite; :5287 records that an absent or unknown value defaults to first-party).
landing_origin(Local, FirstParty) == Local (download.rs:412-417) → holder_claim_for_read returns Announce → the capsule lands unmarked → Held → bondable.
The attacker chooses the store_id/root in the request, so they choose what this operator stakes $DIG on. And because Announce removes an existing marker (module_reshare.rs:337-341), the same call can un-suppress a capsule that was correctly relayed.
Trigger: anyone views the attacker's store in a browser. Publishing a store is permissionless, and viewing one is the product's core action.
Why the two-axis model cannot express the fix
Both the operator's own UI and attacker content served at /s/ are (Local, FirstParty). The axes are transport and cross-site-ness; neither carries "this request was authored by content the node is hosting for a stranger". Same-origin stopped being a trust signal the moment the node began serving untrusted content on its control origin.
That is a shape question, not a patch — settle it before building (§1.10):
- Separate the origins. Serve
/s/* from a distinct port or subdomain so store content is never same-origin with the control plane. Cleanest, and it fixes the whole class rather than this symptom; costs a URL change and whatever depends on store_base_href / reroot_via_referer.
- Derive a third provenance from
Referer/Origin. A request whose referer is a /s/<store>/ path is never FirstParty for landing. Cheaper, but leans on a header and needs the absent-header default to fail closed here while it fails open elsewhere (download.rs:5287).
- Tighten
connect-src. Dropping 'self' from STORE_CSP blocks the callback, but breaks any store that legitimately talks to the node — check whether any does before assuming this is free.
Scope
Deliverable is the assessment plus the chosen fix. Do not widen into a general /s/ sandboxing epic.
connect-src 'self' also lets a store page reach every other open RPC method, not just dig.getContent. Landing/bondability is what this ticket is about; whether the broader surface is a separate defect wants a look, and should be a sibling if so.
Evidence
Traced link-by-link in the tree at a1894e78. Not fired — no exploit was executed against a running node. Everything above is a code read, and step 4 in particular is a claim about browser behaviour that a live check would settle in minutes.
Acceptance
A page served at /s/<store>/ cannot cause a capsule to land Held. Proven by a test asserting that a read attributable to store-served content backfills Suppress, and — per the lesson this family keeps teaching — the test must fail if the decision reverts. Both existing holder_claim_tests passed with the single-axis bug present; a test below the decision is not evidence about the decision.
Task
A store's own page, served by the node at
/s/<store>/, can make the node land attacker-chosen capsules as bondable — and it survives the two-axis fold that fixes the cross-site case (dig-node#436, PR #442).This is not the cross-site variant. That one is fixed:
chrome-extension://and cross-site pages now fold toPeerand landSuppress. This is the same-origin variant, and it is more reachable, because the node's own CSP explicitly permits it.The chain, each link verified in the tree
/s/*pathandPOST /are the SAME router on the SAME port —server.rs:255(.route("/", get(health).post(rpc))) andserver.rs:281(.route("/s/*path", get(store_serve))), oneRouter::new()at:254, served on all three listeners at:2290/:2298/:2306.http://dig.local:<port>/s/<store>/index.htmlandhttp://dig.local:<port>/differ only in path.STORE_CSP(content.rs:96-104) setsscript-src 'self' 'unsafe-inline' 'unsafe-eval' blob:andconnect-src 'self' ….'self'IS the RPC endpoint.Sec-Fetch-Site: same-origin→RequestProvenance::FirstParty(download.rs:397maps only"cross-site"toCrossSite;:5287records that an absent or unknown value defaults to first-party).landing_origin(Local, FirstParty) == Local(download.rs:412-417) →holder_claim_for_readreturnsAnnounce→ the capsule lands unmarked →Held→ bondable.The attacker chooses the
store_id/rootin the request, so they choose what this operator stakes $DIG on. And becauseAnnounceremoves an existing marker (module_reshare.rs:337-341), the same call can un-suppress a capsule that was correctly relayed.Trigger: anyone views the attacker's store in a browser. Publishing a store is permissionless, and viewing one is the product's core action.
Why the two-axis model cannot express the fix
Both the operator's own UI and attacker content served at
/s/are(Local, FirstParty). The axes are transport and cross-site-ness; neither carries "this request was authored by content the node is hosting for a stranger". Same-origin stopped being a trust signal the moment the node began serving untrusted content on its control origin.That is a shape question, not a patch — settle it before building (§1.10):
/s/*from a distinct port or subdomain so store content is never same-origin with the control plane. Cleanest, and it fixes the whole class rather than this symptom; costs a URL change and whatever depends onstore_base_href/reroot_via_referer.Referer/Origin. A request whose referer is a/s/<store>/path is neverFirstPartyfor landing. Cheaper, but leans on a header and needs the absent-header default to fail closed here while it fails open elsewhere (download.rs:5287).connect-src. Dropping'self'fromSTORE_CSPblocks the callback, but breaks any store that legitimately talks to the node — check whether any does before assuming this is free.Scope
Deliverable is the assessment plus the chosen fix. Do not widen into a general
/s/sandboxing epic.connect-src 'self'also lets a store page reach every other open RPC method, not justdig.getContent. Landing/bondability is what this ticket is about; whether the broader surface is a separate defect wants a look, and should be a sibling if so.Evidence
Traced link-by-link in the tree at
a1894e78. Not fired — no exploit was executed against a running node. Everything above is a code read, and step 4 in particular is a claim about browser behaviour that a live check would settle in minutes.Acceptance
A page served at
/s/<store>/cannot cause a capsule to landHeld. Proven by a test asserting that a read attributable to store-served content backfillsSuppress, and — per the lesson this family keeps teaching — the test must fail if the decision reverts. Both existingholder_claim_testspassed with the single-axis bug present; a test below the decision is not evidence about the decision.