Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<capsule>`: 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).
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
49 changes: 49 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<capsule>`: 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.
10 changes: 10 additions & 0 deletions crates/dig-node-core/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,16 @@ that holds a different seam's handle. `dig-node-service`'s `AppState` demonstrat
`content_server: Arc<dyn ContentServer>` (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.
Expand Down
90 changes: 90 additions & 0 deletions crates/dig-node-core/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<capsule>` 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
Expand Down Expand Up @@ -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"
);
}
}
12 changes: 12 additions & 0 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6853,6 +6853,7 @@ mod tests {
"index.html",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served {
Expand Down Expand Up @@ -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)"),
Expand All @@ -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"<h1>hi</h1>"),
Expand Down Expand Up @@ -7006,6 +7009,7 @@ mod tests {
"index.html",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served {
Expand Down Expand Up @@ -7060,6 +7064,7 @@ mod tests {
"index.html",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served {
Expand Down Expand Up @@ -7107,6 +7112,7 @@ mod tests {
"index.html",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served {
Expand Down Expand Up @@ -7154,6 +7160,7 @@ mod tests {
"secret.txt",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served { generation, .. } => {
Expand Down Expand Up @@ -7197,6 +7204,7 @@ mod tests {
"index.html",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served {
Expand Down Expand Up @@ -7240,6 +7248,7 @@ mod tests {
"index.html",
None,
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
assert!(
matches!(out, PlaintextOutcome::RootError { .. }),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -7791,6 +7801,7 @@ mod tests {
"index.html",
None,
origin,
crate::download::RequestProvenance::FirstParty,
));
match out {
PlaintextOutcome::Served { bytes, source, .. } => {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading