From a6eb31858cd974f494f5feed1c2f0820fb8d168a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 16:28:36 +0000 Subject: [PATCH 1/2] test(dig-node): gate JSON-RPC dispatch landing legs on request provenance (#1956) WIP anchor: thread RequestProvenance through dispatch/handle_rpc and fold land_origin at the four getContent/fetchRange landing sites. Tests + Cargo.lock follow. Salvage push before build. Co-Authored-By: Claude --- Cargo.toml | 2 +- crates/dig-node-core/src/download.rs | 17 ++++ crates/dig-node-core/src/lib.rs | 80 ++++++++++++++++++- crates/dig-node-core/src/peer.rs | 11 ++- .../src/seams/content/content_serve.rs | 22 +---- .../src/seams/dig_rpc/dispatch.rs | 43 ++++++++-- crates/dig-node-service/src/control.rs | 10 ++- crates/dig-node-service/src/server.rs | 11 ++- .../tests/openrpc_drift_guard.rs | 2 +- crates/dig-runtime/src/lib.rs | 5 +- 10 files changed, 166 insertions(+), 37 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b6988bb..08ebffb 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.75.5" +version = "0.75.6" # 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/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 167fafb..2700f6a 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -207,6 +207,23 @@ pub fn from_sec_fetch_site(hdr: Option<&str>) -> RequestProvenance { } } +/// Collapse the two landing axes (#1654/#1956) into the single [`ReadOrigin`] the miss-path landing +/// legs (`maybe_backfill_capsule`, the miss-envelope→`fetch_resource`→`spawn_capsule_reshare` chain) +/// gate on. A FIRST-PARTY request keeps its transport origin — a loopback operator read (or a CLI/SDK +/// read with no `Sec-Fetch-*` header) still lands. A CROSS-SITE request — one the browser reports was +/// driven by another origin's page — folds to [`Peer`], so it serves the bytes but effects no durable +/// holder side effect (no cache-write, no DHT announce, no reshare). PURE: the ONE place the two axes +/// meet, shared by both the `/s/` plaintext serve path (#1654) and the JSON-RPC dispatch landing legs +/// (#1956), so the two surfaces can never drift. +/// +/// [`Peer`]: ReadOrigin::Peer +pub(crate) fn landing_origin(origin: ReadOrigin, provenance: RequestProvenance) -> ReadOrigin { + match provenance { + RequestProvenance::FirstParty => origin, + RequestProvenance::CrossSite => ReadOrigin::Peer, + } +} + // -- The digstore-bound proof verifier ------------------------------------------------------------- /// The REAL [`ProofVerifier`] for dig-download's whole-resource check: decodes the digstore diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index c85cd25..415c905 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -2468,6 +2468,7 @@ pub async fn handle_rpc_json( node: &Node, req_json: &str, origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, ) -> String { let req: Value = match serde_json::from_str(req_json) { Ok(v) => v, @@ -2477,7 +2478,7 @@ pub async fn handle_rpc_json( .to_string() } }; - handle_rpc(node, req, origin).await.to_string() + handle_rpc(node, req, origin, provenance).await.to_string() } /// Build a JSON-RPC 2.0 error response envelope. A free function (not the local `err` closure inside @@ -2497,8 +2498,13 @@ fn rpc_err(id: &Value, code: i64, message: &str) -> Value { /// [`RpcDispatch::dispatch`] (seam 4's public surface, `seams/dig_rpc/dispatch.rs`) — relocated /// unchanged. Kept as a free function (rather than requiring every caller to import a trait) so /// no external caller (`dig-node-service`, `dig-runtime`, the peer-RPC server) needed to change. -pub async fn handle_rpc(node: &Node, req: Value, origin: crate::download::ReadOrigin) -> Value { - RpcDispatch::dispatch(node, req, origin).await +pub async fn handle_rpc( + node: &Node, + req: Value, + origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, +) -> Value { + RpcDispatch::dispatch(node, req, origin, provenance).await } /// Return a clone of the JSON-RPC `req` with `params.root` forced to `root_hex` @@ -3807,6 +3813,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"dig.getModuleInfo", "params":{"store_id":store,"root":root}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(held["result"]["total_size"], json!(bytes.len() as u64)); @@ -3821,6 +3828,7 @@ mod tests { json!({"jsonrpc":"2.0","id":2,"method":"dig.getModuleInfo", "params":{"store_id":id_hex(0x99),"root":root}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!( @@ -3846,6 +3854,7 @@ mod tests { json!({"jsonrpc":"2.0","id":3,"method":"dig.fetchModuleRange", "params":{"store_id":store,"root":root,"offset":100,"length":50}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let frame = &resp["result"]; @@ -3869,6 +3878,7 @@ mod tests { json!({"jsonrpc":"2.0","id":4,"method":method, "params":{"store_id":"../../etc/passwd","root":id_hex(1),"length":8}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["error"]["code"], json!(-32602), "{method}"); @@ -3891,6 +3901,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":3,"method":"dig.listCollectionItems","params":{}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["id"], json!(3)); @@ -3906,6 +3917,7 @@ mod tests { json!({"jsonrpc":"2.0","id":4,"method":"dig.listCollectionItems", "params":{"launcher_ids":["nope"]}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["error"]["code"], json!(-32602)); @@ -3921,6 +3933,7 @@ mod tests { json!({"jsonrpc":"2.0","id":5,"method":"dig.listCollectionItems", "params":{"launcher_ids":[], "offset":0, "limit":10}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let result = &resp["result"]; @@ -3945,6 +3958,7 @@ mod tests { json!({"jsonrpc":"2.0","id":6,"method":"dig.listCollectionItems", "params":{"launcher_ids":[], "limit":100000}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["result"]["limit"], json!(200), "limit clamped to 200"); @@ -3960,6 +3974,7 @@ mod tests { json!({"jsonrpc":"2.0","id":8,"method":"dig.getCollection", "params":{"launcher_ids":[], "did":"ab".repeat(32)}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let result = &resp["result"]; @@ -3978,6 +3993,7 @@ mod tests { json!({"jsonrpc":"2.0","id":9,"method":"dig.getCollection", "params":{"launcher_ids":"not-an-array"}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["error"]["code"], json!(-32602)); @@ -4031,6 +4047,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"cache.setCapBytes", "params":{"cap_bytes": five_gib}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(set["result"]["cap_bytes"].as_u64(), Some(five_gib)); @@ -4039,6 +4056,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":2,"method":"cache.getConfig"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(got["result"]["cap_bytes"].as_u64(), Some(five_gib)); assert!(got["result"]["used_bytes"].as_u64().is_some()); @@ -4050,6 +4068,7 @@ mod tests { json!({"jsonrpc":"2.0","id":3,"method":"cache.setCapBytes", "params":{"cap_bytes": 1}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(low["result"]["cap_bytes"].as_u64(), Some(64 * 1024 * 1024)); @@ -4058,6 +4077,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":4,"method":"cache.clear"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert!(cleared["result"].is_object()); @@ -4081,6 +4101,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"control.peers.connect", "params":{"peer":"[::1]:9444"}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert!(resp.get("result").is_none()); assert!( @@ -4106,6 +4127,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"control.peerStatus"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert!(resp["result"].is_object()); assert!( @@ -4142,6 +4164,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"control.listSubscriptions"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(empty["result"]["count"], json!(0)); assert_eq!(empty["result"]["subscriptions"], json!([])); @@ -4152,6 +4175,7 @@ mod tests { json!({"jsonrpc":"2.0","id":2,"method":"control.subscribe", "params":{"store_id": store}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(sub["result"]["subscribed"], json!(true)); assert_eq!(sub["result"]["added"], json!(true)); @@ -4162,6 +4186,7 @@ mod tests { json!({"jsonrpc":"2.0","id":3,"method":"control.subscribe", "params":{"store_id": store}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(again["result"]["added"], json!(false)); @@ -4170,6 +4195,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":4,"method":"control.listSubscriptions"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(listed["result"]["count"], json!(1)); assert_eq!(listed["result"]["subscriptions"], json!([store])); @@ -4181,6 +4207,7 @@ mod tests { json!({"jsonrpc":"2.0","id":5,"method":"control.unsubscribe", "params":{"store_id": store}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(unsub["result"]["removed"], json!(true)); assert!( @@ -4210,6 +4237,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"control.subscribe", "params":{"store_id": "not-hex"}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], json!(CONTROL_ERROR), "-32032"); assert_eq!(resp["error"]["data"]["code"], json!("CONTROL_ERROR")); @@ -4432,6 +4460,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"cache.listCached"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let items = resp["result"]["cached"].as_array().unwrap(); @@ -4477,6 +4506,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"cache.listCached"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let items = resp["result"]["cached"].as_array().unwrap().clone(); @@ -4525,6 +4555,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"cache.stats"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let r = &resp["result"]; @@ -4562,6 +4593,7 @@ mod tests { json!({"jsonrpc":"2.0","id":7,"method":"dig.stage", "params":{"dir": src.path().display().to_string()}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; @@ -4605,6 +4637,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"dig.stage", "params":{"dir": src.path().display().to_string(), "store_id": store}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; let r = &resp["result"]; @@ -4626,6 +4659,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"dig.stage","params":{}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!( @@ -4642,6 +4676,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"dig.stage", "params":{"dir":"/no/such/folder/xyzzy"}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["error"]["code"], -32011, "bad dir ⇒ -32011: {resp}"); @@ -4656,6 +4691,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"dig.stage", "params":{"dir": src.path().display().to_string()}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!( @@ -4674,6 +4710,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"dig.stage", "params":{"dir": src.path().display().to_string(), "store_id":"nothex"}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!( @@ -4695,6 +4732,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"cache.removeCached", "params":{"store_id": store, "root": root}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert!(resp["result"]["removed"].as_bool() == Some(true)); @@ -4781,6 +4819,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"cache.fetchAndCache", "params":{"store_id": store_hex, "root": root_hex}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["result"]["status"].as_str(), Some("cached")); @@ -4808,6 +4847,7 @@ mod tests { json!({"jsonrpc":"2.0","id":2,"method":"cache.fetchAndCache", "params":{"store_id": store_hex, "root": root_hex}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!( @@ -4910,6 +4950,7 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"cache.fetchAndCache", "params":{"store_id": store, "root": root}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["result"]["status"].as_str(), Some("failed")); @@ -5192,6 +5233,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":42,"method":"cache.getConfig"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); let result = got["result"].as_object().expect("result is an object"); @@ -5239,6 +5281,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":7,"method":"control.peerStatus"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); let result = got["result"].as_object().expect("result object"); assert_eq!(result["running"], json!(false)); @@ -5296,6 +5339,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":8,"method":"control.peerStatus"}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); let pool = got["result"]["pool"] .as_object() @@ -5431,6 +5475,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!( @@ -5459,6 +5504,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], ROOT_NOT_ANCHORED, "{resp}"); @@ -5493,6 +5539,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!( @@ -5575,6 +5622,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], ROOT_NOT_ANCHORED, "{resp}"); @@ -5595,6 +5643,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], json!(-32602), "{resp}"); } @@ -6244,6 +6293,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); // It must not have served the stale cached module as the current generation. let served_local = resp["result"]["source"].as_str() == Some("local"); @@ -6275,6 +6325,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); std::env::remove_var("DIG_NODE_PIN"); assert_ne!( @@ -6606,6 +6657,7 @@ mod tests { "retrieval_key": any_rk_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!( resp["error"]["code"], @@ -6647,7 +6699,7 @@ mod tests { ]; for req in cases { let method = req["method"].as_str().unwrap().to_string(); - let resp = rt.block_on(handle_rpc(&node, req, crate::download::ReadOrigin::Local)); + let resp = rt.block_on(handle_rpc(&node, req, crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty)); assert_eq!( resp["error"]["code"], json!(-32601), @@ -6733,6 +6785,7 @@ mod tests { "store_id": store_id.to_hex(), "root": root.to_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert!(resp.get("error").is_none(), "unexpected error: {resp}"); @@ -6781,6 +6834,7 @@ mod tests { "store_id": store_id.to_hex(), "root": root.to_hex(), }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert!( @@ -6808,6 +6862,7 @@ mod tests { "store_id": store_hex, "root": root_hex, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(resp["error"]["code"], json!(-32004), "unexpected: {resp}"); @@ -6824,6 +6879,7 @@ mod tests { &node, json!({"jsonrpc":"2.0","id":1,"method":"dig.getManifest","params":{}}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(empty["error"]["code"], json!(-32602), "{empty}"); @@ -6834,6 +6890,7 @@ mod tests { "store_id": Bytes32([1u8; 32]).to_hex(), "root": "not-hex", }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, ) .await; assert_eq!(bad_root["error"]["code"], json!(-32602), "{bad_root}"); @@ -7390,6 +7447,7 @@ mod tests { "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); // Not held locally, but a provider exists → a REDIRECT (never a silent miss/upstream error). assert_eq!( @@ -7429,6 +7487,7 @@ mod tests { "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); // No provider anywhere → NOT a redirect. The engine yields None and the request falls through // to the upstream proxy, which (unroutable in tests) returns a -32000 upstream error, never a @@ -7465,6 +7524,7 @@ mod tests { "redirect_depth": REDIRECT_HOP_CAP, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_ne!( resp["error"]["code"], @@ -7497,6 +7557,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!( resp["error"]["code"], @@ -7545,6 +7606,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Peer, + crate::download::RequestProvenance::FirstParty, )); // The miss genuinely redirected — proving a live provider made the envelope `Some`, the exact // precondition for `maybe_backfill_capsule` being called at all. @@ -7591,6 +7653,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], json!(CONTENT_REDIRECT), "{resp}"); @@ -7642,6 +7705,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], json!(CONTENT_REDIRECT), "{resp}"); @@ -7687,6 +7751,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["error"]["code"], json!(CONTENT_REDIRECT), "{resp}"); @@ -7747,6 +7812,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); // A fetched-through frame is served (NOT a redirect, NOT a miss): the first frame carries the // reassembled bytes + verification metadata. @@ -8146,6 +8212,7 @@ mod tests { "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk_hex, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!( resp["error"]["code"], @@ -8193,6 +8260,7 @@ mod tests { "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk_hex, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_ne!( resp["error"]["code"], @@ -8230,6 +8298,7 @@ mod tests { "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk_hex, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["result"]["source"], json!("local"), "{resp}"); } @@ -8266,6 +8335,7 @@ mod tests { "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk_hex, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!(resp["result"]["source"], json!("local"), "{resp}"); } @@ -8304,6 +8374,7 @@ mod tests { "redirect_depth": REDIRECT_HOP_CAP, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_ne!( resp["error"]["code"], @@ -8343,6 +8414,7 @@ mod tests { "length": 4096, "offset": 0, }}), crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, )); assert_eq!( resp["error"]["code"], diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index bb7f05c..0e5a814 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1248,7 +1248,16 @@ impl PeerRpcResponder for NodeResponder { return json!({"jsonrpc":"2.0","id":id,"result":{"peers": peers}}); } // This is the peer-RPC server's OWN dispatch — a REMOTE peer's request, always (#179/#1576). - crate::handle_rpc(&self.node, req, crate::download::ReadOrigin::Peer).await + // Provenance is FirstParty here: `landing_origin(Peer, FirstParty) == Peer`, so a peer-wire + // read still never lands (the transport axis already denies it); the Sec-Fetch axis only ever + // applies to browser-driven loopback requests, which never arrive here (#1956). + crate::handle_rpc( + &self.node, + req, + crate::download::ReadOrigin::Peer, + crate::download::RequestProvenance::FirstParty, + ) + .await } async fn handle_availability(&self, items: Value) -> Value { 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 37a6941..baec7fe 100644 --- a/crates/dig-node-core/src/seams/content/content_serve.rs +++ b/crates/dig-node-core/src/seams/content/content_serve.rs @@ -257,24 +257,6 @@ 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). @@ -353,7 +335,7 @@ impl ContentServer for Node { // 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 land_origin = crate::download::landing_origin(origin, provenance); let store_id = match Bytes32::from_hex(store_hex.trim()) { Ok(b) => b, Err(_) => { @@ -1028,7 +1010,7 @@ mod tests { // -- landing_origin: the two-axis collapse (#1654) -------------------------------------------- - use crate::download::{ReadOrigin, RequestProvenance}; + use crate::download::{landing_origin, ReadOrigin, RequestProvenance}; #[test] fn first_party_local_read_still_lands() { diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 93e3b26..63f76de 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -34,12 +34,32 @@ pub trait RpcDispatch: Send + Sync { /// dispatch is shared by every transport (loopback HTTP, in-process FFI, AND the peer-RPC /// server, #179/#1576): it is the single place the two are told apart, so it is threaded through /// EXPLICITLY by each caller rather than inferred here. - async fn dispatch(&self, req: Value, origin: crate::download::ReadOrigin) -> Value; + /// + /// `provenance` is the SECOND, orthogonal axis (#1956): within a loopback HTTP request, whether + /// the browser reports another origin's page drove it (`Sec-Fetch-Site: cross-site`). Like + /// `origin` it is threaded EXPLICITLY (never inferred) — a required param so every transport must + /// state it (fail-safe): a browser-facing POST classifies the header, every trusted/non-browser + /// caller passes [`FirstParty`]. It gates ONLY the miss-path landing legs (never the served + /// bytes) via [`landing_origin`], exactly as the `/s/` serve path does. + /// + /// [`FirstParty`]: crate::download::RequestProvenance::FirstParty + /// [`landing_origin`]: crate::download::landing_origin + async fn dispatch( + &self, + req: Value, + origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, + ) -> Value; } #[async_trait::async_trait] impl RpcDispatch for Node { - async fn dispatch(&self, req: Value, origin: crate::download::ReadOrigin) -> Value { + async fn dispatch( + &self, + req: Value, + origin: crate::download::ReadOrigin, + provenance: crate::download::RequestProvenance, + ) -> Value { // `node` alias: the body below is relocated VERBATIM from the pre-#1285-W1b-5 // `handle_rpc(node: &Node, req: Value)` free function — byte-identical, just bound to // `self` here instead of taking `node` as a parameter. @@ -206,6 +226,12 @@ impl RpcDispatch for Node { // A single range frame of a resource this node holds (the JSON-RPC face of the streamed // peer-transport range fetch; the caller advances `offset` for further windows). The frame // carries the per-range verification metadata on the first window. + // + // Fold the two landing axes ONCE (#1956): a cross-site-driven POST still serves the + // range bytes, but its miss-path landing legs (the miss-envelope→reshare chain AND the + // whole-capsule backfill) gate on `land_origin`, so a same-origin capsule page cannot + // drive this node into becoming a holder. Reads are NEVER altered — only the side effect. + let land_origin = crate::download::landing_origin(origin, provenance); let params = req.get("params").cloned().unwrap_or(json!({})); let store_hex = params.get("store_id").and_then(Value::as_str).unwrap_or(""); let root_hex = params.get("root").and_then(Value::as_str).unwrap_or(""); @@ -273,7 +299,7 @@ impl RpcDispatch for Node { let depth = download::redirect_depth(¶ms); if let Some(envelope) = node .range_miss_envelope( - &id, &content, depth, offset, length, origin, + &id, &content, depth, offset, length, land_origin, ) .await { @@ -281,7 +307,7 @@ impl RpcDispatch for Node { // next read is local (SPEC §14.3). Deduped + detached; no delay here. // `origin` is the SAME gate the reshare leg uses: a peer-origin // miss must never trigger this pull (#1619 follow-up). - node.maybe_backfill_capsule(store_hex, root_hex, origin); + node.maybe_backfill_capsule(store_hex, root_hex, land_origin); return envelope; } } @@ -734,8 +760,13 @@ impl RpcDispatch for Node { if let Some(content) = download::miss_content_for(store_hex, &root_hex, rk_hex) { let depth = download::redirect_depth(¶ms); let pin_hex = pinned_root.map(|r| r.to_hex()); + // Fold the two landing axes ONCE (#1956): a cross-site-driven `dig.getContent` POST still + // serves the bytes, but the miss-path landing legs (the miss-envelope→`fetch_resource`→ + // reshare chain AND the whole-capsule backfill) gate on `land_origin`, so a same-origin + // capsule page cannot drive landing. Reads are NEVER altered — only the side effect. + let land_origin = crate::download::landing_origin(origin, provenance); if let Some(envelope) = node - .content_miss_envelope(&id, &content, depth, offset, pin_hex.as_deref(), origin) + .content_miss_envelope(&id, &content, depth, offset, pin_hex.as_deref(), land_origin) .await { // This resource is being served FROM ANOTHER NODE (a redirect/fetch-through). In the @@ -744,7 +775,7 @@ impl RpcDispatch for Node { // does not delay the current response — it spawns a deduped detached pull and returns. // `origin` is the SAME gate the reshare leg uses: a peer-origin miss must never // trigger this pull (#1619 follow-up). - node.maybe_backfill_capsule(store_hex, &root_hex, origin); + node.maybe_backfill_capsule(store_hex, &root_hex, land_origin); return envelope; } } diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index f73b6ae..6b1f077 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -702,7 +702,15 @@ pub async fn dispatch_control(ctx: &ControlCtx, id: Value, method: &str, params: // not the bind. (The bind is also loopback-only by default and a non-loopback `DIG_NODE_HOST` // is refused unless `DIG_NODE_ALLOW_REMOTE=1` (#1662), but the token gate is what authorizes a // control call regardless of where it arrives from.) - dig_node_core::handle_rpc(&ctx.node, req, dig_node_core::download::ReadOrigin::Local).await + // The control surface is the node's OWN trusted operator (token-gated, no browser / no + // Sec-Fetch context) → first-party by definition, so its reads land exactly as before (#1956). + dig_node_core::handle_rpc( + &ctx.node, + req, + dig_node_core::download::ReadOrigin::Local, + dig_node_core::download::RequestProvenance::FirstParty, + ) + .await } /// Handle a control method OWNED by this shell (guaranteed by [`dispatch_control`] to be a member diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 3f69d9f..4600bef 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -720,6 +720,11 @@ async fn rpc( Json(req): Json, ) -> impl IntoResponse { let origin = read_origin_for(&peer_addr); + // Classify the SECOND landing axis (#1956): the `/s/` serve path (#1654) already gates on + // Sec-Fetch-Site so a cross-site page cannot drive capsule landing; the JSON-RPC POST path must + // gate identically, or a same-origin capsule page could `POST dig.getContent`/`dig.fetchRange` + // and drive this node into becoming a holder. WITHOUT this classification the gate is a no-op. + let provenance = provenance_for(&headers); if !req.is_object() { let id = req.get("id").cloned().unwrap_or(Value::Null); return ( @@ -908,8 +913,10 @@ async fn rpc( // server down on one bad request. let node = state.node.clone(); // `origin` was derived above from the ACCEPTING CONNECTION'S remote address, not assumed. - let resp = match tokio::task::spawn(async move { handle_rpc(&node, normalized, origin).await }) - .await + let resp = match tokio::task::spawn( + async move { handle_rpc(&node, normalized, origin, provenance).await }, + ) + .await { Ok(v) => v, Err(e) => rpc_error( diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index 03c5ffe..3cfc209 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -55,7 +55,7 @@ fn ephemeral_node() -> Arc { /// numeric JSON-RPC error code, or `None` when the read path returned a `result`. async fn dispatch_error_code(node: &Node, method: &str) -> Option { let req = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": {} }); - let resp = handle_rpc(node, req, dig_node_core::download::ReadOrigin::Local).await; + let resp = handle_rpc(node, req, dig_node_core::download::ReadOrigin::Local, dig_node_core::download::RequestProvenance::FirstParty).await; resp.get("error") .and_then(|e| e.get("code")) .and_then(|c| c.as_i64()) diff --git a/crates/dig-runtime/src/lib.rs b/crates/dig-runtime/src/lib.rs index 3f05059..9ea8fdc 100644 --- a/crates/dig-runtime/src/lib.rs +++ b/crates/dig-runtime/src/lib.rs @@ -106,11 +106,14 @@ const NODE_UNAVAILABLE_JSONRPC: &str = r#"{"jsonrpc":"2.0","id":null,"error":{"c /// wallet-only guard is testable without the process-global runtime. fn dispatch_node_rpc(node: Option<&Node>, rt: &tokio::runtime::Runtime, req: &str) -> String { match node { - // The in-process FFI node engine (the browser BEING the node, #44/#47) — always Local. + // The in-process FFI node engine (the browser BEING the node, #44/#47) — always Local, and + // the operator's OWN navigation (no cross-site Sec-Fetch context) → FirstParty, so its reads + // land as before (#1956). Some(node) => rt.block_on(dig_node_core::handle_rpc_json( node, req, dig_node_core::download::ReadOrigin::Local, + dig_node_core::download::RequestProvenance::FirstParty, )), None => NODE_UNAVAILABLE_JSONRPC.to_string(), } From 8ddcb85c8d4527f248e7ef201adb909d8423d568 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 16:37:59 +0000 Subject: [PATCH 2/2] fix(dig-node): gate JSON-RPC dispatch landing legs on request provenance (#1956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST / JSON-RPC dig.getContent/dig.fetchRange landing legs gated only on the transport-axis ReadOrigin, not the #1654 Sec-Fetch-Site provenance axis, so a SAME-ORIGIN capsule page could POST dig.getContent and drive capsule landing (warm/backfill/reshare) — the CSRF door the /s/ serve gate already closes. Thread RequestProvenance through RpcDispatch::dispatch, handle_rpc, and handle_rpc_json (a required arg, fail-safe like origin). server.rs's POST handler classifies Sec-Fetch-Site via the existing provenance_for and passes it through; all trusted/non-browser callers pass FirstParty. Move landing_origin beside ReadOrigin/RequestProvenance in download.rs as the one shared fold, and apply it at ALL FOUR landing sites (range_miss_envelope + maybe_backfill_capsule in fetchRange; content_miss_envelope + maybe_backfill_capsule in getContent) so the reshare leg is closed too. Reads are never altered — bytes serve identically. Co-Authored-By: Claude --- Cargo.lock | 2 +- SPEC.md | 31 ++- crates/dig-node-core/src/lib.rs | 234 +++++++++++++++++- .../src/seams/dig_rpc/dispatch.rs | 24 +- crates/dig-node-service/src/server.rs | 70 ++++-- .../tests/openrpc_drift_guard.rs | 8 +- 6 files changed, 335 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f999106..5d70568 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,7 +2281,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.75.5" +version = "0.75.6" dependencies = [ "async-trait", "axum", diff --git a/SPEC.md b/SPEC.md index 40200fa..5ca3ee5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3999,19 +3999,32 @@ 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. +1. **Every browser-reachable read surface derives a request-provenance label from the `Sec-Fetch-Site` + request header**, orthogonal to the §21.7 read-origin label. This covers BOTH the `/s/` plaintext + serve surface AND the `POST /` JSON-RPC read methods (`dig.getContent`, `dig.fetchRange`), whose + miss-path landing legs (the implicit warm/backfill/reshare fired when the resource is not held + locally) would otherwise let a SAME-ORIGIN capsule page `POST dig.getContent` and drive landing — + the loopback address labels it `Local`, so §21.7 alone permits it. 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. +3. **The collapse is applied ONCE per landing site via the shared `landing_origin(origin, provenance)` + fold**, and the collapsed `land_origin` flows to the landing legs; the leaf gates (§21.7) are + unchanged. On the `/s/` path the fold is applied at the serve seam; on the `POST /` JSON-RPC path it + is applied at the top of each read handler and the collapsed origin replaces the raw origin at every + landing site (`content_miss_envelope`/`range_miss_envelope` → the reshare chain, AND + `maybe_backfill_capsule`) — the raw read-origin is used everywhere else. Each transport threads the + provenance EXPLICITLY through `handle_rpc`/`dispatch` (a required argument, never inferred): the + browser-facing HTTP POST classifies the header, every trusted/non-browser caller (the control + surface, the in-process FFI, the peer-RPC server) passes `FirstParty`. This axis applies only to + browser-reachable HTTP surfaces; the peer wire remains gated by §21.7's read-origin alone + (`landing_origin(Peer, FirstParty) == Peer`, so a peer read still never lands). It complements §21.9's + token-gate on the EXPLICIT `cache.fetchAndCache` landing method. 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 diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 415c905..7f8cc92 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6699,7 +6699,12 @@ mod tests { ]; for req in cases { let method = req["method"].as_str().unwrap().to_string(); - let resp = rt.block_on(handle_rpc(&node, req, crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty)); + let resp = rt.block_on(handle_rpc( + &node, + req, + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); assert_eq!( resp["error"]["code"], json!(-32601), @@ -7767,6 +7772,233 @@ mod tests { ); } + // -- #1956: the JSON-RPC POST landing legs gate on request PROVENANCE (Sec-Fetch-Site) ---------- + // + // The transport-axis (`ReadOrigin`) tests above prove a PEER-origin miss never lands. These prove + // the SECOND axis: a same-origin capsule page that `POST`s `dig.getContent`/`dig.fetchRange` over + // the LOOPBACK transport (origin = Local) still cannot drive landing when the browser reports the + // request was CROSS-SITE — matching the #1654 `/s/` serve gate. The read is NEVER altered: the + // redirect envelope (the served bytes' locator) is returned identically; only the durable holder + // side effect (backfill/reshare warm) is withheld. Each mirrors the `origin`-axis control exactly, + // varying ONLY the `provenance` argument, so a dropped `land_origin` fold at ANY of the four + // landing sites turns one of these RED. + + /// **Proves (#1956):** a CROSS-SITE `dig.fetchRange` POST over the loopback transport still SERVES + /// (the miss redirects to the provider — the bytes' locator) but spawns NO background backfill — + /// the CSRF door the `origin` axis alone left open on the JSON-RPC path. + /// **Catches:** the `land_origin` fold being dropped at the fetchRange landing sites + /// (`range_miss_envelope` and/or `maybe_backfill_capsule`) — then a cross-site page drives landing. + #[test] + fn cross_site_post_fetchrange_serves_but_does_not_land() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); + std::env::remove_var("DIG_NODE_ON_MISS"); + std::env::remove_var("DIG_NODE_BACKFILL_ON_MISS"); // default ON + let rt = pin_test_rt(); + let (store, tip, rk) = miss_setup(); + let (node, td) = test_node(None); + let node = Arc::new(node); + node.set_self_ref(Arc::downgrade(&node)); + let cid = ContentId::resource(store.0, tip.0, [0xcd; 32]); + attach_p2p( + &node, + vec![dig_download::testkit::mock_provider(5, &cid)], + dig_download::testkit::MockContent::even(10, 1), + MissMode::Redirect, + &td, + ); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":9,"method":"dig.fetchRange","params":{ + "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, + "length": 4096, "offset": 0, + }}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::CrossSite, + )); + // The read STILL serves — a cross-site request is never throttled, only its landing is denied. + assert_eq!( + resp["error"]["code"], + json!(CONTENT_REDIRECT), + "a cross-site fetchRange must still serve (redirect to the provider): {resp}" + ); + + let key = format!("{}:{}", store.to_hex(), tip.to_hex()); + assert!( + !node.capsule_acquisition.is_warming(&key), + "a cross-site fetchRange POST must NOT spawn a backfill, even over the loopback transport" + ); + } + + /// **Proves (#1956):** a CROSS-SITE `dig.getContent` POST still serves the miss (redirect) but + /// spawns NO backfill — the sibling handler, whose miss-envelope leg forwards origin into the + /// reshare chain, is gated identically. + /// **Catches:** the `land_origin` fold being dropped at the getContent landing sites + /// (`content_miss_envelope` and/or `maybe_backfill_capsule`). + #[test] + fn cross_site_post_getcontent_serves_but_does_not_land() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); + std::env::remove_var("DIG_NODE_ON_MISS"); + std::env::remove_var("DIG_NODE_BACKFILL_ON_MISS"); + let rt = pin_test_rt(); + let (store, tip, rk) = miss_setup(); + let (node, td) = test_node_with_resolver(None, MockResolver::one(&store.to_hex(), tip)); + let node = Arc::new(node); + node.set_self_ref(Arc::downgrade(&node)); + let cid = ContentId::resource(store.0, tip.0, [0xcd; 32]); + attach_p2p( + &node, + vec![dig_download::testkit::mock_provider(3, &cid)], + dig_download::testkit::MockContent::even(10, 1), + MissMode::Redirect, + &td, + ); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getContent","params":{ + "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, + }}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::CrossSite, + )); + assert_eq!( + resp["error"]["code"], + json!(CONTENT_REDIRECT), + "a cross-site getContent must still serve (redirect to the provider): {resp}" + ); + + let key = format!("{}:{}", store.to_hex(), tip.to_hex()); + assert!( + !node.capsule_acquisition.is_warming(&key), + "a cross-site getContent POST must NOT spawn a backfill" + ); + } + + /// **The control (#1956):** the identical `dig.getContent` miss at FIRST-PARTY provenance DOES + /// spawn a backfill — proving the fold does not simply disable landing, and that a legitimate + /// same-site / CLI / SDK read (no `Sec-Fetch-*` header ⇒ FirstParty) still lands (frictionless). + #[test] + fn first_party_post_getcontent_still_lands() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); + std::env::remove_var("DIG_NODE_ON_MISS"); + std::env::remove_var("DIG_NODE_BACKFILL_ON_MISS"); + let rt = pin_test_rt(); + let (store, tip, rk) = miss_setup(); + let (node, td) = test_node_with_resolver(None, MockResolver::one(&store.to_hex(), tip)); + let node = Arc::new(node); + node.set_self_ref(Arc::downgrade(&node)); + let cid = ContentId::resource(store.0, tip.0, [0xcd; 32]); + attach_p2p( + &node, + vec![dig_download::testkit::mock_provider(3, &cid)], + dig_download::testkit::MockContent::even(10, 1), + MissMode::Redirect, + &td, + ); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getContent","params":{ + "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, + }}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["error"]["code"], json!(CONTENT_REDIRECT), "{resp}"); + + let key = format!("{}:{}", store.to_hex(), tip.to_hex()); + assert!( + node.capsule_acquisition.is_warming(&key), + "a first-party local getContent miss (the control) must still land" + ); + } + + /// **Proves (#1956):** a PEER-transport `dig.getContent` miss is unaffected by the provenance axis — + /// `landing_origin(Peer, FirstParty) == Peer`, so it serves but never lands, exactly as before. The + /// two axes compose; provenance can only ever tighten landing, never loosen the transport denial. + #[test] + fn peer_transport_post_getcontent_unaffected() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); + std::env::remove_var("DIG_NODE_ON_MISS"); + std::env::remove_var("DIG_NODE_BACKFILL_ON_MISS"); + let rt = pin_test_rt(); + let (store, tip, rk) = miss_setup(); + let (node, td) = test_node_with_resolver(None, MockResolver::one(&store.to_hex(), tip)); + let node = Arc::new(node); + node.set_self_ref(Arc::downgrade(&node)); + let cid = ContentId::resource(store.0, tip.0, [0xcd; 32]); + attach_p2p( + &node, + vec![dig_download::testkit::mock_provider(3, &cid)], + dig_download::testkit::MockContent::even(10, 1), + MissMode::Redirect, + &td, + ); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getContent","params":{ + "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, + }}), + crate::download::ReadOrigin::Peer, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["error"]["code"], json!(CONTENT_REDIRECT), "{resp}"); + + let key = format!("{}:{}", store.to_hex(), tip.to_hex()); + assert!( + !node.capsule_acquisition.is_warming(&key), + "a peer-transport getContent miss must never land, regardless of provenance" + ); + } + + /// **Proves (#1956):** the SERVED response is byte-identical whether the request is FirstParty or + /// CrossSite — provenance touches ONLY the landing side effect, never the read. Two fresh nodes run + /// the identical getContent miss under the two provenances; the returned envelopes must be equal. + /// **Catches:** provenance ever leaking onto the serve/response path (the frictionless-read trap). + #[test] + fn read_bytes_identical_regardless_of_provenance() { + fn serve_miss(provenance: crate::download::RequestProvenance) -> Value { + let rt = pin_test_rt(); + let (store, tip, rk) = miss_setup(); + let (node, td) = test_node_with_resolver(None, MockResolver::one(&store.to_hex(), tip)); + let node = Arc::new(node); + node.set_self_ref(Arc::downgrade(&node)); + let cid = ContentId::resource(store.0, tip.0, [0xcd; 32]); + attach_p2p( + &node, + vec![dig_download::testkit::mock_provider(3, &cid)], + dig_download::testkit::MockContent::even(10, 1), + MissMode::Redirect, + &td, + ); + rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getContent","params":{ + "store_id": store.to_hex(), "root": tip.to_hex(), "retrieval_key": rk, + }}), + crate::download::ReadOrigin::Local, + provenance, + )) + } + + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); + std::env::remove_var("DIG_NODE_ON_MISS"); + std::env::remove_var("DIG_NODE_BACKFILL_ON_MISS"); + let first_party = serve_miss(crate::download::RequestProvenance::FirstParty); + let cross_site = serve_miss(crate::download::RequestProvenance::CrossSite); + assert_eq!( + first_party, cross_site, + "the served response must be identical across provenance — only landing differs" + ); + } + #[test] fn fetch_through_pulls_from_the_holder_and_serves_the_bytes() { let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 63f76de..0c38f09 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -9,9 +9,11 @@ //! `match Method::from_name(..)` dispatch byte-identical). `async_trait`-boxed (matching the //! other seam traits) so it stays dyn-compatible for the future `Arc` handle //! (W1c). The crate-root `handle_rpc`/`handle_rpc_json` free functions (every external caller's -//! entry point — `dig-node-service`, `dig-runtime`, the peer-RPC server) now thinly delegate to -//! [`RpcDispatch::dispatch`] — their signatures are UNCHANGED, so no caller anywhere needed to -//! change. +//! entry point — `dig-node-service`, `dig-runtime`, the peer-RPC server) thinly delegate to +//! [`RpcDispatch::dispatch`]. Each carries the request's `origin` (transport axis) AND its +//! `provenance` (the `Sec-Fetch-Site` landing axis, #1956) — both threaded EXPLICITLY by every +//! caller so a transport can never forget to state who is asking or whether a cross-site page drove +//! the request. use serde_json::{json, Value}; @@ -299,7 +301,12 @@ impl RpcDispatch for Node { let depth = download::redirect_depth(¶ms); if let Some(envelope) = node .range_miss_envelope( - &id, &content, depth, offset, length, land_origin, + &id, + &content, + depth, + offset, + length, + land_origin, ) .await { @@ -766,7 +773,14 @@ impl RpcDispatch for Node { // capsule page cannot drive landing. Reads are NEVER altered — only the side effect. let land_origin = crate::download::landing_origin(origin, provenance); if let Some(envelope) = node - .content_miss_envelope(&id, &content, depth, offset, pin_hex.as_deref(), land_origin) + .content_miss_envelope( + &id, + &content, + depth, + offset, + pin_hex.as_deref(), + land_origin, + ) .await { // This resource is being served FROM ANOTHER NODE (a redirect/fetch-through). In the diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 4600bef..01f8e98 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -688,8 +688,10 @@ 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, +/// Classify a request's landing PROVENANCE (#1654/#1956) from its `Sec-Fetch-Site` header — the +/// second landing axis over [`read_origin_for`], applied identically to the `/s/` plaintext serve +/// path AND the `POST /` JSON-RPC path (`dig.getContent`/`dig.fetchRange`). 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 @@ -913,18 +915,19 @@ async fn rpc( // server down on one bad request. let node = state.node.clone(); // `origin` was derived above from the ACCEPTING CONNECTION'S remote address, not assumed. - let resp = match tokio::task::spawn( - async move { handle_rpc(&node, normalized, origin, provenance).await }, - ) - .await - { - Ok(v) => v, - Err(e) => rpc_error( - id.clone(), - ErrorCode::DispatchFailed, - format!("dig-node: dispatch failed: {e}"), - ), - }; + let resp = + match tokio::task::spawn( + async move { handle_rpc(&node, normalized, origin, provenance).await }, + ) + .await + { + Ok(v) => v, + Err(e) => rpc_error( + id.clone(), + ErrorCode::DispatchFailed, + format!("dig-node: dispatch failed: {e}"), + ), + }; // If dig-node didn't resolve the method, relay it blindly to the upstream. if resp @@ -1982,11 +1985,13 @@ async fn shutdown_signal() { #[cfg(test)] mod tests { use super::{ - is_allowed_origin, is_app_origin, is_local_origin, peer_tier_status, read_origin_for, - served_response, ServeProvenance, StorePath, APP_ORIGINS_ENV, EXPOSED_DIG_HEADERS, + is_allowed_origin, is_app_origin, is_local_origin, peer_tier_status, provenance_for, + read_origin_for, served_response, ServeProvenance, StorePath, APP_ORIGINS_ENV, + EXPOSED_DIG_HEADERS, }; + use axum::http::HeaderMap; use dig_node_core::content_serve::{PeerTier, ServeSource}; - use dig_node_core::download::ReadOrigin; + use dig_node_core::download::{ReadOrigin, RequestProvenance}; use serde_json::json; use std::net::{Ipv4Addr, SocketAddr}; @@ -2068,6 +2073,37 @@ mod tests { ); } + /// **Proves (#1956):** the `POST /` JSON-RPC path derives its landing provenance from the SAME + /// `Sec-Fetch-Site` classifier the `/s/` serve path uses — a `cross-site` header denies landing, an + /// ABSENT header (a CLI/SDK client, or a same-origin navigation) is first-party so it still lands. + /// This is the load-bearing wiring: without `provenance_for` on the POST handler the whole gate is + /// a no-op (constant FirstParty). The `rpc` handler passes exactly this value into `handle_rpc`. + #[test] + fn provenance_is_read_on_the_post_path() { + let mut cross = HeaderMap::new(); + cross.insert("sec-fetch-site", "cross-site".parse().unwrap()); + assert_eq!( + provenance_for(&cross), + RequestProvenance::CrossSite, + "a cross-site POST must classify as CrossSite so its landing legs are denied" + ); + + // No Sec-Fetch-Site header (a non-browser client, or a same-origin request) ⇒ first-party. + assert_eq!( + provenance_for(&HeaderMap::new()), + RequestProvenance::FirstParty, + "an absent header must be first-party — a CLI/SDK read must never be mistaken for cross-site" + ); + + let mut same = HeaderMap::new(); + same.insert("sec-fetch-site", "same-origin".parse().unwrap()); + assert_eq!( + provenance_for(&same), + RequestProvenance::FirstParty, + "a same-origin POST still lands — only an explicit cross-site value denies landing" + ); + } + #[test] fn read_origin_for_classifies_ipv4_mapped_loopback_as_local() { // #1664b: on a `::` dual-stack bind an IPv4 loopback client arrives as the diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index 3cfc209..235cd58 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -55,7 +55,13 @@ fn ephemeral_node() -> Arc { /// numeric JSON-RPC error code, or `None` when the read path returned a `result`. async fn dispatch_error_code(node: &Node, method: &str) -> Option { let req = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": {} }); - let resp = handle_rpc(node, req, dig_node_core::download::ReadOrigin::Local, dig_node_core::download::RequestProvenance::FirstParty).await; + let resp = handle_rpc( + node, + req, + dig_node_core::download::ReadOrigin::Local, + dig_node_core::download::RequestProvenance::FirstParty, + ) + .await; resp.get("error") .and_then(|e| e.get("code")) .and_then(|c| c.as_i64())