Skip to content

fix(cache,serve): store-page landing provenance, single cache budget, unrecognised-dir accounting - #455

Draft
MichaelTaylor3d wants to merge 6 commits into
mainfrom
loop/batch-cache
Draft

fix(cache,serve): store-page landing provenance, single cache budget, unrecognised-dir accounting#455
MichaelTaylor3d wants to merge 6 commits into
mainfrom
loop/batch-cache

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DRAFT — do not merge. The gate round has not returned. #450 is a security fix and wants the full security gate.

Closes #450
Closes #284
Closes #265

Cache-accounting, serving and landing-provenance batch. Three tickets landed; four in the original batch are not in this PR and are noted at the bottom with what was learned about each.


#450 — a store's own page could land attacker-chosen capsules bondable (SECURITY)

/s/*path and POST / are the same router on the same port, so a store page is same-origin with the RPC endpoint, and STORE_CSP grants store pages script-src 'unsafe-inline' 'unsafe-eval' with connect-src 'self' — where 'self' is that endpoint. The browser then truthfully reports Sec-Fetch-Site: same-origin, which mapped to FirstParty, folded to Local, and returned Announce: the capsule landed Held, i.e. bondable, with the operator's $DIG staked on a stranger's chosen content. Because Announce also removes an existing marker, the same call could un-suppress a capsule that had been correctly relayed.

Shape decision (settled before building, §1.10): a THIRD provenance, derived from Sec-Fetch-Site alone.

The two-axis model could not express the fix — the operator's own read and attacker content at /s/ are both (Local, FirstParty). Same-origin stopped being a trust signal the moment the node began serving untrusted content on its control origin.

Why not the ticket's three options as written:

  • Separate the origins is the right long-term shape and fixes the whole class, but it is a URL change with cross-repo consumers (store_base_href, reroot_via_referer, dig-app, the extension). Too wide for a live exploit.
  • Referer-derived is bypassable by exactly the party it constrains: a store page sets its own referrer-policy and can reduce Referer to origin-only or remove it entirely, and either makes the request look non-store. A one-line <meta> tag defeats it.
  • Tighten connect-src is insufficient, not merely costly: img-src 'self' alone lets a page issue GET /s/<other-store>/big and land it, and CSP cannot express a per-path distinction.

What the fix rests on instead: /s/* is the only HTML surface this router serves (/, /health, /version, /openrpc.json, /.well-known/*, /ws, /ws/status, /verify/* are all non-HTML). So a page-driven request on this origin was, by construction, authored by store content. Sec-Fetch-Site is browser-set and a forbidden header name, so page script can neither forge nor strip it.

Sec-Fetch-Site provenance lands?
absent FirstParty yes — non-browser CLI/SDK, unchanged
none FirstParty yes — user-initiated top-level nav; script can never produce this value
same-origin / same-site / unknown StoreServed (new) no — folds to Peer
cross-site CrossSite no — unchanged

The flywheel survives: the top-level navigation that opens a store is none and still lands the capsule; subresources are then served from that landed capsule. The stated cost is narrow — a subresource no longer lands on its own. The unknown arm now fails closed (it failed open). The read is never blocked either way.

#284 — one budget enforced twice over two subtrees

cache_cap_bytes() was read independently by both eviction paths, so a node configured for N bytes held close to 2N. cache_budget() now splits the one cap into a responses share (an eighth) and a modules share (the rest); each sweep spends only its own. used_bytes and cap_bytes now describe the same thing, so dig-app's storage warning can no longer show an impossible state.

A reserved share rather than "modules take what responses leave", because the latter starves the response cache — its small regenerable windows always lose the race to ~135 MiB capsules.

#265 — unrecognised directories escaped the cap

Semantics chosen and now documented in the code: COUNT, never DELETE. The sweep does not exclusively own <cache>/modules, and deleting files it cannot identify is the class of thing that is fine until the once it is not. Unrecognised bytes are charged against the modules share, so recognised capsules are evicted to compensate. unrecognised_module_bytes() measures the subtree and subtracts what the scan would return, so the two halves partition by construction rather than via two filters kept in agreement by hand — which is how the original defect arose.


Blast radius

.gitnexus is stale for this repo, so this was done with git grep + direct reads rather than impact — stated per §2.0, which permits the fallback but requires saying so.

  • RequestProvenance — a pub enum gaining a variant. Every match site enumerated and checked: download.rs (landing_origin), dispatch.rs (holder_claim_for_landing, plus its two call sites), capsule_store.rs, push_capsule.rs, lib.rs (construction sites, all FirstParty), server.rs (provenance_for). Only landing_origin matches on the enum exhaustively; every other site constructs or forwards. cargo check -p dig-node-service --all-targets clean.
  • from_sec_fetch_site — one production caller (server.rs:988), feeding both the /s/ and POST / doors.
  • cache_cap_bytes — the two eviction sweeps (both changed) plus the reporting sites (dispatch.rs, /health), which are unchanged and now consistent.
  • scan_cached_modules — one production caller (evict_modules_locked), now joined by unrecognised_module_bytes.

Evidence

  • cargo test -p dig-node-core --lib1028 passed, 0 failed.
  • cargo test -p dig-node-service --test content_serve15 passed, 0 failed.
  • cargo clippy -p dig-node-core -p dig-node-service --all-targets -- -D warnings — exit 0.
  • cargo fmt --all -- --check — exit 0.

Revert-proofs, each committed first and reverted by file copy (never git checkout):

reverted result
the Sec-Fetch-Site mapping only 3 of 6 FAILED
the fold only (StoreServed => origin) the new dispatch test FAILED; all four pre-existing holder_claim_tests PASSED
both sweeps back to cache_cap_bytes() one_budget_bounds_both_cache_subtrees_together FAILED
unrecognised = 0 unrecognised_bytes_under_modules_are_counted_but_never_deleted FAILED

The second row is the one worth reading: the four #436 tests are green with the #450 bug present, exactly as the ticket predicted, which is why the new test is written against StoreServed over a Local transport rather than against a landed marker further down.

Fixture notes, since this family keeps producing false greens:

SemVer

0.189.0 to 0.190.0 (minor). A pub enum without #[non_exhaustive] gains a variant, which is breaking for a downstream exhaustive match, and landing behaviour changes — under 0.x that is a minor.

Unverified / not done

MichaelTaylor3d and others added 2 commits August 31, 2026 06:39
Stub commit so the lane's branch and draft PR exist before implementation.

Co-Authored-By: Claude <noreply@anthropic.com>
…es bondable

A store's own page could make the node land attacker-chosen capsules Held,
and so bondable with the operator's $DIG staked on a stranger's content.

`/s/*path` and `POST /` are the same router on the same port, so a store page
is same-origin with the RPC endpoint, and STORE_CSP grants store pages
`script-src 'unsafe-inline'` with `connect-src 'self'` -- where 'self' IS that
endpoint. The browser then truthfully reports `Sec-Fetch-Site: same-origin`,
which mapped to FirstParty, folded to Local, and returned Announce. Because
Announce also removes an existing marker, the same call could un-suppress a
capsule that had been correctly relayed.

The two-axis model could not express the fix: the operator's own read and
attacker content served at /s/ are both (Local, FirstParty). Same-origin
stopped being a trust signal the moment the node began serving untrusted
content on its control origin, so this adds a THIRD provenance rather than a
tighter reading of two.

RequestProvenance::StoreServed covers every page-driven Sec-Fetch-Site value
and folds to Peer. `none` (a user-initiated top-level navigation, unforgeable
by page script) stays FirstParty, so opening a store in a browser still lands
its capsule and the reshare flywheel survives. An absent header still means a
non-browser CLI/SDK client and still lands. An unknown value now fails CLOSED.

Deliberately not Referer-derived: a page controls its own referrer-policy and
can strip the path or the header, so a Referer rule is bypassable by exactly
the party it constrains. Sec-Fetch-* is browser-set and forbidden to script.

Refs #450

Co-Authored-By: Claude <noreply@anthropic.com>
… count against it

Two defects with one root: what the configured cap is a bound ON.

#284 -- `cache_cap_bytes()` was read independently by both eviction paths over
two different subtrees, so neither knew the other existed and a node
configured for N bytes held close to 2N. `cache_budget()` now splits the one
cap into a responses share (an eighth) and a modules share, so the two sweeps
spend halves of one budget. A reserved share rather than 'modules take what
responses leave' because the latter starves the response cache: its small
regenerable windows always lose the race to ~135 MiB capsules.

#265 -- the scan's hex64 filter governed BOTH the total measured AND the
candidate set, so anything under <cache>/modules the node could not identify
was invisible to the bound while consuming the disk the bound protects, and
could grow without limit. Semantics chosen and now written down in the code:
COUNT, never DELETE. Unrecognised bytes are charged against the modules share,
so recognised capsules are evicted to compensate; the sweep never removes a
file it cannot identify from a directory it does not exclusively own.

used_bytes and cap_bytes now describe the same thing.

Refs #284, #265

Co-Authored-By: Claude <noreply@anthropic.com>
…udget

SPEC 21.8 gains the three-outcome Sec-Fetch-Site mapping (StoreServed, the
unknown arm failing closed, and the explicit ban on deriving provenance from
Referer/Origin), and states why 'none' must stay FirstParty.

SPEC 7.10 states that cache_cap_bytes is ONE budget over the whole tree, split
into a reserved responses share and a modules share, and that unrecognised
bytes under modules are counted but never deleted.

Refs #450, #284, #265

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 2 commits August 31, 2026 07:47
Co-Authored-By: Claude <noreply@anthropic.com>
`provenance_is_read_on_the_post_path` pinned `same-origin -> FirstParty` with the
comment "a same-origin POST still lands". That is the dig-node#450 defect stated
as a requirement: `/s/*path` and `POST /` share one router and port, and
`STORE_CSP` grants `connect-src 'self'` -- where `'self'` IS the RPC endpoint --
so a store's own page reached `dig.getContent` as same-origin, landed its capsule
`Held`, and staked this operator's $DIG on a stranger's content.

The test asserted the behaviour the fix exists to remove, which is why it went RED
on the fix rather than on the defect. Third instance of this shape found today.

Now asserts the real contract, with the two controls that stop an over-correction:
`none` (a user-initiated navigation, which script can never produce) stays
FirstParty so the operator's own reads still land, and an unrecognised value fails
CLOSED where it previously failed open.

Refs #450

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant