From 79b8aa7b6f812a21a8364e0d123daed82407fab7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 09:59:26 +0000 Subject: [PATCH 1/2] test(cache): add RequestProvenance parser + tests for landing gate (#1654) First failing-test anchor for gating capsule-landing side effects on request provenance (Sec-Fetch-Site) rather than a loopback peer address. Refs #1654 Co-Authored-By: Claude --- crates/dig-node-core/src/download.rs | 90 ++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 25d7374..1258e1b 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -170,6 +170,43 @@ pub enum ReadOrigin { Peer, } +/// Whether an HTTP read is a FIRST-PARTY navigation (the user's own top-level request, a same-site +/// subresource, or a non-browser client) or a CROSS-SITE subresource driven by some OTHER origin's +/// page. This is a SECOND axis, ORTHOGONAL to [`ReadOrigin`]: `ReadOrigin` is the TRANSPORT the +/// request arrived on (loopback/FFI vs the peer wire), while `RequestProvenance` describes WITHIN a +/// loopback HTTP request whether the browser tells us another site drove it. +/// +/// It exists because "the peer address is loopback" is NOT "the operator authorized this": a +/// malicious web page can make a cross-site `GET dig.local/s/` and — while the bytes are +/// harmless to serve — the LANDING side effect (cache write → this node becomes a DHT holder, +/// SPEC §14.3/§21.3) is a durable, remotely-triggerable amplification. Gating landing on first-party +/// provenance closes that CSRF door WITHOUT ever throttling the read: the bytes always serve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestProvenance { + /// A first-party request: the user's own navigation, a same-origin/same-site subresource, a + /// direct address-bar hit, OR any non-browser client (CLI/SDK send no `Sec-Fetch-*` header). + FirstParty, + /// A cross-site subresource: the browser explicitly reported `Sec-Fetch-Site: cross-site`, + /// meaning some OTHER origin's page drove this request. The read still serves; landing does not. + CrossSite, +} + +/// Classify a request's provenance from its `Sec-Fetch-Site` header value (already extracted from +/// the header map; `None` when the header is absent). +/// +/// ONLY an explicit, case-insensitive `cross-site` denies landing. Everything else — `same-origin`, +/// `same-site`, `none`, an unknown value, AND (critically) an ABSENT header — is [`FirstParty`], so +/// non-browser clients that never send `Sec-Fetch-*` (the CLI, the SDK) are never mistaken for a +/// cross-site attacker. Absence must NEVER map to `CrossSite`. +/// +/// [`FirstParty`]: RequestProvenance::FirstParty +pub fn from_sec_fetch_site(hdr: Option<&str>) -> RequestProvenance { + match hdr.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("cross-site") => RequestProvenance::CrossSite, + _ => RequestProvenance::FirstParty, + } +} + // -- The digstore-bound proof verifier ------------------------------------------------------------- /// The REAL [`ProofVerifier`] for dig-download's whole-resource check: decodes the digstore @@ -3157,4 +3194,57 @@ pub(crate) mod tests { "a recorded bad-descriptor verdict must persist across a restart" ); } + + // -- RequestProvenance::from_sec_fetch_site (Sec-Fetch-Site → landing-gate axis) --------------- + + #[test] + fn sec_fetch_site_cross_site_is_cross_site() { + assert_eq!( + from_sec_fetch_site(Some("cross-site")), + RequestProvenance::CrossSite, + "an explicit cross-site fetch must be classified CrossSite so landing is suppressed" + ); + } + + #[test] + fn sec_fetch_site_first_party_values_are_first_party() { + for value in ["same-origin", "same-site", "none"] { + assert_eq!( + from_sec_fetch_site(Some(value)), + RequestProvenance::FirstParty, + "{value} is a first-party fetch and must land normally" + ); + } + } + + #[test] + fn sec_fetch_site_absent_is_first_party() { + // LOAD-BEARING: non-browser clients (CLI/SDK) send no Sec-Fetch-* header. Absence must map + // to FirstParty, never CrossSite — otherwise every CLI/SDK read would stop landing. + assert_eq!( + from_sec_fetch_site(None), + RequestProvenance::FirstParty, + "an absent Sec-Fetch-Site header must be treated as first-party" + ); + } + + #[test] + fn sec_fetch_site_is_case_insensitive_and_trims() { + assert_eq!( + from_sec_fetch_site(Some(" Cross-Site ")), + RequestProvenance::CrossSite, + "the header match must be trimmed + case-insensitive" + ); + } + + #[test] + fn sec_fetch_site_unknown_value_is_first_party() { + // Only an explicit "cross-site" denies landing; an unrecognized value fails OPEN (serves + + // lands) so a future/odd Sec-Fetch-Site value never silently breaks landing. + assert_eq!( + from_sec_fetch_site(Some("wat")), + RequestProvenance::FirstParty, + "an unknown Sec-Fetch-Site value must default to first-party" + ); + } } From 76e4aa1078ee51aa85c75a5d075ab86771694eb1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 10:24:39 +0000 Subject: [PATCH 2/2] fix(cache): gate capsule landing on request provenance, not loopback (#1654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A loopback peer address proves the connection is local, not that the operator authorized the request. A cross-site browser navigation (GET dig.local/s/) would otherwise LAND the attacker's chosen capsule (warm + reshare + DHT holder-announce, SPEC §14.3/§21.3) — a remotely-triggerable amplification. The bytes are harmless (public content); the durable holder side effect is the door. - New RequestProvenance axis parsed from Sec-Fetch-Site (download.rs): only an explicit cross-site denies landing; absence (CLI/SDK) and every other value are first-party. Threaded into ContentServer::serve_content_plaintext + collapsed once via landing_origin(origin, provenance) so a cross-site read serves but folds landing to Peer. Reads are never blocked. - cache.fetchAndCache over HTTP POST / now requires the control/paired token (it is an explicit become-a-holder call); the in-process FFI cache.* path stays open. - SPEC (service §21.8/§21.9/§21.10 + core seam note), README, DEVELOPMENT_LOG. Version bumped 0.74.3 -> 0.74.4. Closes #1654 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- DEVELOPMENT_LOG.md | 17 +++ README.md | 2 +- SPEC.md | 49 ++++++++ crates/dig-node-core/SPEC.md | 10 ++ crates/dig-node-core/src/lib.rs | 12 ++ .../src/seams/content/content_serve.rs | 77 ++++++++++++- crates/dig-node-service/src/server.rs | 86 +++++++++++++- .../dig-node-service/tests/content_serve.rs | 105 +++++++++++++++++- crates/dig-node-service/tests/server.rs | 40 +++++++ 11 files changed, 385 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6695cfa..5c48864 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,7 +2281,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.74.3" +version = "0.74.4" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5264e26..5798e86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.74.3" +version = "0.74.4" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index f741875..1da42d0 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -947,3 +947,20 @@ from its DHT announce. The fix reuses the #1576 reshare leg's `ChainAnchoredModu seam (resolve the chain-anchored root, re-hash, compare) and refuses BEFORE the write, so an unverified capsule never lands and is never announced. General lesson: a defense that only runs on one of several exit paths is not a defense — verify at every seam that admits an artifact. + +**Loopback != operator-authorized; provenance is a SECOND axis over ReadOrigin (#1654).** The read-origin +gate (#1576) labels a `/s/` read `Local` when the connection is loopback — but a loopback address only +proves the CONNECTION is local, not that the OPERATOR authorized the request. A browser running an +attacker's page can issue a cross-site `GET dig.local/s/`: loopback ⇒ `Local` ⇒ the attacker's +chosen capsule LANDS (warm + reshare + DHT holder-announce), a remotely-triggerable amplification of the +attacker's choosing at the cost of a few bytes. The bytes themselves are harmless (public content); the +durable holder side effect is the vulnerability. Fix: a second orthogonal axis, `RequestProvenance`, +derived from the browser's own `Sec-Fetch-Site` header — only an explicit `cross-site` is `CrossSite`; +absence is `FirstParty` (CLI/SDK send no `Sec-Fetch-*`, and treating absence as cross-site would silently +stop every CLI/SDK read from landing). Landing fires only when BOTH `Local` AND `FirstParty`; a cross-site +read collapses its landing origin to `Peer` (`landing_origin(origin, provenance)`), serving the bytes +identically but effecting nothing. Also token-gated `cache.fetchAndCache` over HTTP (it is an explicit +"become a holder" call, not a public read); the in-process FFI `cache.*` path stays open. General lesson: +a transport-derived trust label (loopback) can be a NECESSARY but not SUFFICIENT condition — a CSRF-class +attacker rides the trusted transport, so a durable side effect needs a second axis the attacker cannot +forge (here, the browser's own cross-site self-report). diff --git a/README.md b/README.md index 10cfb17..0210fb4 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ in-process node expose: | `dig.getAnchoredRoot` | The store's **chain-anchored tip root**, resolved on-chain by walking the DataStore singleton lineage on coinset.org (the trusted root for the extension's `chia://` root-pinning). | | `dig.getManifest` | A capsule's (`storeId:rootHash`) embedded **normalized public manifest** (data-section id 13): the store's complete public file surface as of that commit — `{ schema_version, entries: [{ path, latest_root, generation_index, sha256_latest, version_count }] }`. Served **local-first** when this node holds the requested capsule. `null` (never an error) when the module carries no manifest section (an older `.dig`, or a private store); `-32004` when the capsule isn't held locally at all. | | `cache.getConfig` / `cache.setCapBytes` / `cache.clear` | On-disk cache config: `{ cap_bytes (floored at 64 MiB), used_bytes, cache_dir, shared }` — `cache_dir` is the effective dir and `shared` whether it is the canonical dir shared with the DIG Browser (#96). | -| `cache.listCached` / `cache.removeCached` / `cache.fetchAndCache` | Cached-capsule manager (`storeId:rootHash`). | +| `cache.listCached` / `cache.removeCached` / `cache.fetchAndCache` | Cached-capsule manager (`storeId:rootHash`). Over HTTP, `cache.fetchAndCache` is **local-token gated** (like `control.*`): it makes this node a durable DHT holder of the requested capsule, so it is not a public read. The in-process FFI `cache.*` path stays open. | | `rpc.discover` | **Method discovery** — returns this node's OpenRPC document (the standard OpenRPC discovery method), so a client can introspect every method + error over the wire with no out-of-band knowledge. | | `control.*` | **CONTROL / admin surface** (loopback-only + **local-token gated** — see below). Manage the node: hosted/pinned stores, cache, §21 sync, config. Read methods above stay open; only `control.*` requires the token. | | `dig.getProof`, `dig.listCapsules`, *anything else* | **Blind passthrough** — relayed verbatim to the upstream, so the node stays a correct transparent proxy for methods it doesn't resolve locally. | diff --git a/SPEC.md b/SPEC.md index 14d3487..f917c02 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3965,3 +3965,52 @@ The peer-tier read (`dig.fetchRange` / `dig.getContent` on the peer wire) and th (`GET /s/…`, and the root-absolute reroot that shares its path) are BOTH subject to this rule — the serve path reaches the same legs through its P2P tier, so gating one door and not the other leaves the property unheld. + +### 21.8. Landing has a SECOND axis — request provenance (MUST) + +A loopback remote address proves the CONNECTION is local; it does NOT prove the OPERATOR authorized the +request. A browser running an attacker's page can issue a cross-site `GET dig.local/s/`: the +address is loopback, so §21.7 alone would label the read `Local` and let the attacker's chosen capsule +LAND (warm + reshare + holder-announce). The read itself is harmless — the bytes are public — but the +durable holder side effect is a remotely-triggerable amplification. + +Landing therefore gates on BOTH axes: + +1. **The `/s/` HTTP surface derives a request-provenance label from the `Sec-Fetch-Site` request + header**, orthogonal to the §21.7 read-origin label. ONLY an explicit, case-insensitive `cross-site` + value is `CrossSite`; `same-origin`, `same-site`, `none`, an unknown value, AND an ABSENT header are + all `FirstParty`. Absence MUST map to `FirstParty` — non-browser clients (the CLI, the SDK) send no + `Sec-Fetch-*` header, and treating absence as cross-site would silently stop every CLI/SDK read from + landing. +2. **A read lands only when it is BOTH `Local` (§21.7) AND `FirstParty`.** A `CrossSite` request collapses + its landing origin to `Peer`: the bytes are served identically, but no warm, reshare, promotion, or + announce fires. The READ MUST NEVER be blocked, throttled, or altered by provenance — only the side + effect is suppressed. +3. **The collapse is applied ONCE, at the serve seam**, and the collapsed origin flows to the landing + legs; the leaf gates (§21.7) are unchanged. This axis is HTTP-only (`serve_content_plaintext` has no + peer-wire callers); the peer tier remains gated by §21.7's read-origin alone. + +Honest residuals: a browser predating `Sec-Fetch-Site` (all current major browsers send it) presents no +header and is treated as first-party; and a same-origin store-to-store request within a shared serving +origin is first-party by construction. These are accepted — the axis closes the cross-site CSRF door, +not every conceivable same-origin confusion. + +### 21.9. The `cache.fetchAndCache` HTTP surface is token-gated (MUST) + +`cache.fetchAndCache` explicitly makes this node fetch, cache, and DHT-announce a capsule of the +CALLER'S naming — the §21.3 holder side effect on demand. Over the HTTP `POST /` surface a loopback +address does not prove operator intent (a cross-site page can POST to `dig.local`), so the method MUST +require the local control token (the `X-Dig-Control-Token` header or `params._control_token`) OR a valid +paired controller token, exactly like a `control.*` method; an unauthorized call is rejected +`UNAUTHORIZED` (-32030) before any landing work. The in-process FFI `cache.*` path is the operator's own +process and MUST stay open — it never traverses this HTTP handler. Reads remain ungated. + +### 21.10. Reverse-proxy trust caveat (informative) + +The `Local` label trusts the loopback boundary. Behind a loopback-terminating reverse proxy every remote +client appears to the node as a `Local` connection; `X-Forwarded-For` is explicitly NOT trusted for the +origin label (a remote client can forge it). An operator who deliberately fronts the node with a proxy +would need a future explicit trusted-proxy configuration — an allowlist of proxy addresses plus an +authenticated proxy-supplied client-address header — before any forwarded address could be believed. No +such configuration exists today; running the node behind an untrusted-header proxy forfeits the origin +gate. diff --git a/crates/dig-node-core/SPEC.md b/crates/dig-node-core/SPEC.md index 7ba0918..6c90f1a 100644 --- a/crates/dig-node-core/SPEC.md +++ b/crates/dig-node-core/SPEC.md @@ -1135,6 +1135,16 @@ that holds a different seam's handle. `dig-node-service`'s `AppState` demonstrat `content_server: Arc` (used by the loopback plaintext serve path, `server:: serve_resource`/`serve_miss`) — the SAME object, two shapes. +**`ContentServer::serve_content_plaintext` takes TWO landing axes (#1654).** `origin` +(`ReadOrigin`, §21.7 of the service SPEC) is the transport label derived from the accepting +connection's remote address; `provenance` (`RequestProvenance`, from the `/s/` request's +`Sec-Fetch-Site` header via `download::from_sec_fetch_site`) says whether a first-party navigation or a +cross-site page drove the request. The serve seam collapses them ONCE — `landing_origin(origin, +provenance)` = `origin` when first-party, else `Peer` — and passes the collapsed origin to the +landing legs (`maybe_backfill_capsule`, the peer tier's reshare); the READ tiers still use `origin`. +The bytes are served identically regardless of provenance — only the durable holder side effect is +gated. Absence of the header is first-party (CLI/SDK clients send none). See the service SPEC §21.8. + **The binary holds no seam logic.** `dig-node-service` wires seam handles + HTTP/control transport; the actual dispatch/serve/cache/chain/peer/key logic lives in `dig-node-core`'s seam modules. A second competing binary, or seam logic leaking into the service crate, is an architecture violation. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index cb699be..5c89e5b 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6853,6 +6853,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { @@ -6890,6 +6891,7 @@ mod tests { "assets/app.js", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert!( matches!(js, PlaintextOutcome::Served { ref bytes, .. } if bytes == b"console.log(1)"), @@ -6903,6 +6905,7 @@ mod tests { "", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert!( matches!(bare, PlaintextOutcome::Served { ref bytes, .. } if bytes == b"

hi

"), @@ -7006,6 +7009,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { @@ -7060,6 +7064,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { @@ -7107,6 +7112,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { @@ -7154,6 +7160,7 @@ mod tests { "secret.txt", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { generation, .. } => { @@ -7197,6 +7204,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { @@ -7240,6 +7248,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert!( matches!(out, PlaintextOutcome::RootError { .. }), @@ -7711,6 +7720,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); std::env::remove_var("DIG_NODE_PIN"); match out { @@ -7791,6 +7801,7 @@ mod tests { "index.html", None, origin, + crate::download::RequestProvenance::FirstParty, )); match out { PlaintextOutcome::Served { bytes, source, .. } => { @@ -7938,6 +7949,7 @@ mod tests { "index.html", None, crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); std::env::remove_var("DIG_NODE_PIN"); match out { diff --git a/crates/dig-node-core/src/seams/content/content_serve.rs b/crates/dig-node-core/src/seams/content/content_serve.rs index f73e3c5..37a6941 100644 --- a/crates/dig-node-core/src/seams/content/content_serve.rs +++ b/crates/dig-node-core/src/seams/content/content_serve.rs @@ -257,6 +257,24 @@ enum ProxyMiss { /// unchanged from this module (W1b-1) — a behaviour-preserving trait extraction, not a new /// implementation. `async_trait`-boxed (matching [`crate::shared::AnchoredRootResolver`]) so the /// trait stays dyn-compatible for the future `Arc` handle (W1c). +/// Collapse the two landing axes (#1654) into the single [`ReadOrigin`] the miss-path landing legs +/// (`maybe_backfill_capsule`, `peer_serve_plaintext`→reshare) gate on. A first-party request keeps +/// its transport origin (a loopback operator read still lands); a CROSS-SITE request — driven by +/// another origin's page — folds to [`Peer`] so it serves the bytes but effects no durable holder +/// side effect. PURE: the ONE place the two axes meet. +/// +/// [`ReadOrigin`]: crate::download::ReadOrigin +/// [`Peer`]: crate::download::ReadOrigin::Peer +fn landing_origin( + origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, +) -> crate::download::ReadOrigin { + match provenance { + crate::download::RequestProvenance::FirstParty => origin, + crate::download::RequestProvenance::CrossSite => crate::download::ReadOrigin::Peer, + } +} + #[async_trait::async_trait] pub trait ContentServer: Send + Sync { /// Serve a store resource as DECRYPTED plaintext over the trusted loopback surface (#289). @@ -284,6 +302,12 @@ pub trait ContentServer: Send + Sync { /// `spawn_capsule_reshare` behind the peer tier's `fetch_resource`); a `Peer` read is served /// but effects nothing, so a stranger can never drive this node into pulling, caching, and /// DHT-announcing capsules of the STRANGER'S choosing (#1576). + /// + /// `provenance` is the SECOND landing axis (#1654): even on a `Local` (loopback) connection, a + /// browser-reported `Sec-Fetch-Site: cross-site` marks the request as driven by another origin's + /// page (a CSRF vector). The bytes ALWAYS serve regardless of provenance; only the landing side + /// effects fold to `Peer` for a cross-site request, so a malicious page cannot make this node + /// land + DHT-announce a capsule of its choosing. Non-browser clients send no header ⇒ first-party. async fn serve_content_plaintext( &self, store_hex: &str, @@ -291,6 +315,7 @@ pub trait ContentServer: Send + Sync { resource_key: &str, salt_hex: Option<&str>, origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, ) -> PlaintextOutcome; /// The store's public file PATHS at `(store, root)` from the embedded `PublicManifest` (id 13), @@ -322,7 +347,13 @@ impl ContentServer for Node { resource_key: &str, salt_hex: Option<&str>, origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, ) -> PlaintextOutcome { + // The landing axis (#1654): a cross-site request serves the bytes but must NOT trigger the + // network-effecting landing legs (whole-capsule backfill + reshare/DHT-announce), so a + // malicious page can never CSRF this node into becoming a holder of a capsule it chose. + // The READ tiers still use `origin`; only the LANDING calls below use `land_origin`. + let land_origin = landing_origin(origin, provenance); let store_id = match Bytes32::from_hex(store_hex.trim()) { Ok(b) => b, Err(_) => { @@ -482,12 +513,12 @@ impl ContentServer for Node { pinned_root, salt.as_ref(), verified, - origin, + land_origin, ) .await { // A peer served the resource; warm the whole capsule locally for next time (#290). - self.maybe_backfill_capsule(store_hex, &root_hex, origin); + self.maybe_backfill_capsule(store_hex, &root_hex, land_origin); return with_serve_metadata(peer, owner_puzzle_hash, generation, peer_tier); } } @@ -498,7 +529,7 @@ impl ContentServer for Node { Ok((ciphertext, proof, chunk_lens)) => { let trusted = pinned_root.unwrap_or(proof.root); // Warm the whole capsule locally so the next read is local-first (#290). - self.maybe_backfill_capsule(store_hex, &root_hex, origin); + self.maybe_backfill_capsule(store_hex, &root_hex, land_origin); return match verify_and_decrypt( &store_id, effective_key, @@ -559,7 +590,7 @@ impl ContentServer for Node { }; } Err(ProxyMiss::NotFound) => { - self.maybe_backfill_capsule(store_hex, &root_hex, origin); + self.maybe_backfill_capsule(store_hex, &root_hex, land_origin); return PlaintextOutcome::NotFound { root_hex: root_hex.clone(), }; @@ -994,4 +1025,42 @@ mod tests { "decrypting under the wrong URN key must fail closed" ); } + + // -- landing_origin: the two-axis collapse (#1654) -------------------------------------------- + + use crate::download::{ReadOrigin, RequestProvenance}; + + #[test] + fn first_party_local_read_still_lands() { + // A same-site / header-absent (CLI/SDK) request keeps its Local origin, so a legitimate + // operator read still triggers the whole-capsule warm + reshare (the #290 flywheel). + assert_eq!( + landing_origin(ReadOrigin::Local, RequestProvenance::FirstParty), + ReadOrigin::Local, + "a first-party local read must keep landing" + ); + } + + #[test] + fn cross_site_local_read_does_not_land() { + // The CSRF door (#1654): a loopback connection whose browser reports cross-site provenance + // folds to Peer, so the bytes serve but no durable holder side effect fires. + assert_eq!( + landing_origin(ReadOrigin::Local, RequestProvenance::CrossSite), + ReadOrigin::Peer, + "a cross-site read must NOT land, even on a loopback connection" + ); + } + + #[test] + fn a_peer_read_never_lands_regardless_of_provenance() { + // A genuine peer-wire read never lands; provenance can only ever tighten, never loosen. + for provenance in [RequestProvenance::FirstParty, RequestProvenance::CrossSite] { + assert_eq!( + landing_origin(ReadOrigin::Peer, provenance), + ReadOrigin::Peer, + "a peer read must never land" + ); + } + } } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 556eb70..3c7ccc9 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -687,6 +687,20 @@ fn read_origin_for(peer_addr: &SocketAddr) -> dig_node_core::download::ReadOrigi } } +/// Classify a `/s/` request's landing PROVENANCE (#1654) from its `Sec-Fetch-Site` header — the +/// second landing axis over [`read_origin_for`]. A loopback address proves the CONNECTION is local, +/// but not that the operator authorized the request: a malicious web page can drive a cross-site +/// `GET dig.local/s/`, and the durable LANDING side effect (cache write → DHT holder, +/// SPEC §14.3/§21.3) would then be remotely triggerable. The browser reports the driving origin in +/// `Sec-Fetch-Site`; a `cross-site` value folds landing to `Peer` while the bytes still serve. A +/// non-browser client (CLI/SDK) sends no header ⇒ first-party. See +/// [`dig_node_core::download::from_sec_fetch_site`]. +fn provenance_for(headers: &HeaderMap) -> dig_node_core::download::RequestProvenance { + dig_node_core::download::from_sec_fetch_site( + headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()), + ) +} + /// `POST /` — JSON-RPC. Normalises the request params for dig-node, dispatches via /// `handle_rpc`, and returns the node's JSON-RPC envelope. A non-object body (e.g. /// a batch array, which dig-node does not handle) is rejected in-band so the client @@ -843,6 +857,40 @@ async fn rpc( ); } + // LANDING gate (#1654): `cache.fetchAndCache` makes this node fetch + cache + DHT-announce a + // capsule of the CALLER'S choosing — a durable holder side effect (SPEC §14.3/§21.3). Over the + // HTTP surface a loopback address does not prove the operator authorized the call (a cross-site + // page can POST to `dig.local`), so it requires the control token exactly like `control.*`: + // the master control token OR a valid paired token. The in-process FFI `cache.*` path stays open + // (SYSTEM.md) — it never reaches this HTTP `rpc` handler. Reads remain ungated. + if method == "cache.fetchAndCache" { + let header_tok = headers + .get(control::CONTROL_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()); + let presented = control::presented_token(header_tok, &req); + // Direct constant-time compare (NOT `control::is_authorized`, which fails OPEN for any + // non-`control.*` method): either the master control token or a valid paired token. + let master_ok = presented + .as_deref() + .is_some_and(|tok| control::ct_eq(tok, &state.control_token)); + let paired_ok = presented.as_deref().is_some_and(|tok| { + pairing::is_paired_token(&pairing::paired_tokens_path(&state.state_dir), tok) + }); + if !(master_ok || paired_ok) { + return ( + StatusCode::OK, + Json(rpc_error( + id, + ErrorCode::Unauthorized, + "cache.fetchAndCache requires the local control token (X-Dig-Control-Token \ + header or params._control_token) or a paired controller token (see \ + `dig-node pair`): it makes this node a durable DHT holder of the requested \ + capsule, so it is not a public read", + )), + ); + } + } + // Keep the original request for a possible passthrough relay (the upstream must // see exactly what the client sent, not the dig-node-normalised form). let original = req.clone(); @@ -1230,10 +1278,19 @@ async fn ws_wallet_session(mut socket: WebSocket, state: AppState) { async fn store_serve( State(state): State, ConnectInfo(peer_addr): ConnectInfo, + headers: HeaderMap, Path(path): Path, ) -> Response { match parse_store_path(&path) { - Some(sp) => serve_resource(&state, sp, read_origin_for(&peer_addr)).await, + Some(sp) => { + serve_resource( + &state, + sp, + read_origin_for(&peer_addr), + provenance_for(&headers), + ) + .await + } None => not_found(), } } @@ -1269,7 +1326,15 @@ async fn fallback_serve( ) -> Response { let referer = headers.get(header::REFERER).and_then(|v| v.to_str().ok()); match reroot_via_referer(referer, uri.path()) { - Some(sp) => serve_resource(&state, sp, read_origin_for(&peer_addr)).await, + Some(sp) => { + serve_resource( + &state, + sp, + read_origin_for(&peer_addr), + provenance_for(&headers), + ) + .await + } None => not_found(), } } @@ -1284,13 +1349,14 @@ async fn serve_resource( state: &AppState, sp: StorePath, origin: dig_node_core::download::ReadOrigin, + provenance: dig_node_core::download::RequestProvenance, ) -> Response { let root = sp.root.as_deref().unwrap_or(""); // Public stores only for now (salt = None): a private store's secret salt is not yet provisioned // to the local serve surface, so such a store fails closed at decrypt (a documented follow-up). match state .content_server - .serve_content_plaintext(&sp.store_id, root, &sp.resource, None, origin) + .serve_content_plaintext(&sp.store_id, root, &sp.resource, None, origin, provenance) .await { PlaintextOutcome::Served { @@ -1314,7 +1380,9 @@ async fn serve_resource( generation, }, ), - PlaintextOutcome::NotFound { root_hex } => serve_miss(state, &sp, &root_hex, origin).await, + PlaintextOutcome::NotFound { root_hex } => { + serve_miss(state, &sp, &root_hex, origin, provenance).await + } PlaintextOutcome::InvalidParams { message } => { error_response(StatusCode::BAD_REQUEST, &message) } @@ -1337,6 +1405,7 @@ async fn serve_miss( sp: &StorePath, root_hex: &str, origin: dig_node_core::download::ReadOrigin, + provenance: dig_node_core::download::RequestProvenance, ) -> Response { if is_static_asset_path(&sp.resource) { return not_found(); @@ -1353,7 +1422,14 @@ async fn serve_miss( // SPA fallback: the store's default view, served against the SAME resolved root. match state .content_server - .serve_content_plaintext(&sp.store_id, root_hex, "index.html", None, origin) + .serve_content_plaintext( + &sp.store_id, + root_hex, + "index.html", + None, + origin, + provenance, + ) .await { PlaintextOutcome::Served { diff --git a/crates/dig-node-service/tests/content_serve.rs b/crates/dig-node-service/tests/content_serve.rs index 61d0927..d909dbc 100644 --- a/crates/dig-node-service/tests/content_serve.rs +++ b/crates/dig-node-service/tests/content_serve.rs @@ -594,12 +594,17 @@ async fn rpc_verification_failure_is_recorded_and_fails_closed() { #[derive(Default)] struct RecordingContentServer { origins: std::sync::Mutex>, + provenances: std::sync::Mutex>, } impl RecordingContentServer { fn recorded(&self) -> Vec { self.origins.lock().unwrap().clone() } + + fn recorded_provenances(&self) -> Vec { + self.provenances.lock().unwrap().clone() + } } #[async_trait::async_trait] @@ -611,8 +616,10 @@ impl dig_node_core::ContentServer for RecordingContentServer { _resource_key: &str, _salt_hex: Option<&str>, origin: dig_node_core::download::ReadOrigin, + provenance: dig_node_core::download::RequestProvenance, ) -> dig_node_core::content_serve::PlaintextOutcome { self.origins.lock().unwrap().push(origin); + self.provenances.lock().unwrap().push(provenance); dig_node_core::content_serve::PlaintextOutcome::NotFound { root_hex: String::new(), } @@ -632,13 +639,16 @@ impl dig_node_core::ContentServer for RecordingContentServer { } } -/// Drive `GET ` through the real router from `peer` (a forged connection remote address) and -/// return every `ReadOrigin` the content server was asked with. -async fn origins_seen_for( +/// Drive `GET ` through the REAL router from `peer` (a forged connection remote address), +/// optionally carrying a `Referer` and a `Sec-Fetch-Site` header, and return the recording content +/// server (the reads it saw) plus the HTTP status. The single low-level seam behind the origin- and +/// provenance-observing helpers. +async fn drive_s_get( peer: &str, path: &str, referer: Option<&str>, -) -> Vec { + sec_fetch_site: Option<&str>, +) -> (Arc, axum::http::StatusCode) { use tower::ServiceExt; let hold = env_guard().lock_owned().await; @@ -672,6 +682,9 @@ async fn origins_seen_for( if let Some(referer) = referer { builder = builder.header(axum::http::header::REFERER, referer); } + if let Some(sfs) = sec_fetch_site { + builder = builder.header("sec-fetch-site", sfs); + } let mut request = builder.body(axum::body::Body::empty()).unwrap(); request.extensions_mut().insert(axum::extract::ConnectInfo( peer.parse::() @@ -687,8 +700,19 @@ async fn origins_seen_for( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "a missing ConnectInfo would be an axum rejection (500), not a defaulted origin" ); + let status = response.status(); drop(EnvHold(hold)); - recorder.recorded() + (recorder, status) +} + +/// Drive `GET ` through the real router from `peer` and return every `ReadOrigin` the content +/// server was asked with. +async fn origins_seen_for( + peer: &str, + path: &str, + referer: Option<&str>, +) -> Vec { + drive_s_get(peer, path, referer, None).await.0.recorded() } /// **Proves:** `GET /s/…` labels its read from the CONNECTION — a non-loopback reader is `Peer` @@ -745,6 +769,77 @@ async fn fallback_serve_labels_a_rerooted_read_from_the_connection() { ); } +// -- `/s/` RequestProvenance derivation (#1654) -------------------------------------------------- +// +// A loopback address proves the CONNECTION is local, not that the operator authorized the request: +// a malicious page can drive a cross-site `GET dig.local/s/`, and the durable LANDING side +// effect (cache write → DHT holder) would then be remotely triggerable. `Sec-Fetch-Site: cross-site` +// is the browser's own report that another origin drove the request; the node threads it to the +// serve seam so the LANDING legs (but never the READ) can suppress it. These prove the header is +// carried to the seam exactly; the core `landing_origin` unit test proves the collapse it drives. + +/// **Proves:** a loopback `GET /s/…` carrying `Sec-Fetch-Site: cross-site` still SERVES (the read is +/// frictionless — same status as an ordinary miss) but reaches the serve seam labelled `CrossSite`, +/// so the landing legs fold to `Peer` and no durable holder side effect fires. A same-site request +/// and a header-ABSENT request (a CLI/SDK client) both reach the seam as `FirstParty`, so a +/// legitimate read still lands. +/// +/// **Catches:** the `Sec-Fetch-Site` header being dropped between the transport and the serve seam, +/// or absence being mistaken for cross-site (which would silently stop every CLI/SDK read landing). +#[tokio::test] +async fn store_serve_labels_provenance_from_sec_fetch_site_without_blocking_the_read() { + use dig_node_core::download::RequestProvenance; + let store = "31".repeat(32); + let path = format!("/s/{store}/app/index.html"); + + // Cross-site: the browser reports another origin drove this loopback request. + let (cross, cross_status) = + drive_s_get("127.0.0.1:51240", &path, None, Some("cross-site")).await; + assert!( + cross + .recorded_provenances() + .iter() + .all(|p| *p == RequestProvenance::CrossSite) + && !cross.recorded_provenances().is_empty(), + "a cross-site read must reach the seam as CrossSite, got {:?}", + cross.recorded_provenances() + ); + + // Same-site: a first-party subresource — must land normally. + let (same, same_status) = drive_s_get("127.0.0.1:51241", &path, None, Some("same-site")).await; + assert!( + same.recorded_provenances() + .iter() + .all(|p| *p == RequestProvenance::FirstParty) + && !same.recorded_provenances().is_empty(), + "a same-site read must reach the seam as FirstParty, got {:?}", + same.recorded_provenances() + ); + + // Header ABSENT (a CLI/SDK client sends no Sec-Fetch-*): must be FirstParty, never CrossSite. + let (absent, absent_status) = drive_s_get("127.0.0.1:51242", &path, None, None).await; + assert!( + absent + .recorded_provenances() + .iter() + .all(|p| *p == RequestProvenance::FirstParty) + && !absent.recorded_provenances().is_empty(), + "an absent Sec-Fetch-Site must reach the seam as FirstParty, got {:?}", + absent.recorded_provenances() + ); + + // The READ is never blocked by provenance: all three miss identically (the recorder serves + // NotFound → the SPA/404 decision), so a cross-site read is exactly as served as a same-site one. + assert_eq!( + cross_status, same_status, + "a cross-site read must not be blocked — same status as a same-site read" + ); + assert_eq!( + same_status, absent_status, + "a header-absent read must not be blocked either" + ); +} + /// **Regression (#1763):** during the ~30 s cold-start window BEFORE the peer network attaches, a /// content read still succeeds — but it is served by the public gateway, having never consulted the /// peer tier at all. Before this fix the response was indistinguishable from a post-attach gateway diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index c70c693..78700f3 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -794,6 +794,46 @@ async fn control_method_without_token_is_rejected_with_unauthorized() { assert!(resp.get("result").is_none(), "no result on a rejected call"); } +/// **Proves (#1654):** `cache.fetchAndCache` over the HTTP `POST /` surface is token-gated like +/// `control.*` — an untokened call is UNAUTHORIZED (-32030) and never reaches the landing dispatch, +/// while the master control token gets PAST the gate (the response is not the unauthorized error). +/// The method makes this node a durable DHT holder of the requested capsule, so it is not a public +/// read; the in-process FFI `cache.*` path (which never reaches this handler) stays open. +#[tokio::test] +async fn cache_fetch_and_cache_over_http_requires_the_control_token() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, token, _hold) = start_companion_full(&upstream).await; + + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "cache.fetchAndCache", + "params": { "store_id": "31".repeat(32) } + }); + + // Untokened → rejected at the gate before any landing work. + let rejected = post_rpc(&addr, body.clone(), None).await; + assert_eq!(rejected["error"]["code"], json!(-32030)); + assert_eq!(rejected["error"]["data"]["code"], json!("UNAUTHORIZED")); + assert!( + rejected.get("result").is_none(), + "no result on a rejected landing call" + ); + + // With the master control token → PAST the gate (whatever the dispatch then returns, it is not + // the landing gate's UNAUTHORIZED). + let authorized = post_rpc(&addr, body, Some(&token)).await; + let is_unauthorized = authorized + .get("error") + .and_then(|e| e.get("data")) + .and_then(|d| d.get("code")) + .is_some_and(|c| c == &json!("UNAUTHORIZED")); + assert!( + !is_unauthorized, + "a control-token call must clear the landing gate, got {authorized:?}" + ); +} + #[tokio::test] async fn control_method_with_wrong_token_is_rejected() { let (upstream, _calls) = start_mock_upstream().await;