Skip to content

feat(mirror): advertised URLs this node can be fetched at (#426) - #430

Merged
MichaelTaylor3d merged 8 commits into
mainfrom
loop/426-advertised-url
Aug 30, 2026
Merged

feat(mirror): advertised URLs this node can be fetched at (#426)#430
MichaelTaylor3d merged 8 commits into
mainfrom
loop/426-advertised-url

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #426. Child of https://github.com/DIG-Network/dig_ecosystem/issues/3166.

A mirror coin publishes, in its memos, the URLs its store can be fetched from, and
dig-mirror-coin::create refuses an advertisement carrying none. This PR gives the node
that value, and then makes it reach the coin.

What it does

Half one — the operator surface. mirror/advertise.rs turns one operator-set
DIG_MIRROR_ADVERTISE_URLS value into the advertisement's URL list. Entries are separated
by commas or whitespace, duplicates are dropped, and the operator's ORDER is published
verbatim: §5.2 recommends listing IPv6 first, but these are the operator's own fetch hints
and silently sorting them would misstate a preference the node does not hold.

The value is configured and never derived. The one address this machine could infer —
the STUN reflexive address — is exactly the wrong thing to publish: a coin's URLs are fixed
at create for the whole epoch, so an address that is unreachable from outside or that simply
changes leaves real $DIG staked on a claim SPEC.md §25 penalises.

An entry whose host can only mean this machine (loopback, unspecified, link-local,
localhost, dig.local) is dropped with a warning naming the reason; its publishable
siblings in the same list survive. A private/LAN address IS published — that is a deliberate
operator choice risking only their own stake.

Half two — the wiring. spawn_mirror_passes previously handed NodeMirrorEffects an
empty list, so every create refused by name and no mirror coin could ever be made. It now
reads the operator's list ONCE at bring-up through advertise::configured_urls — once,
because the value is a configuration rather than an observation, and re-reading it per pass
would let the list a warning was emitted about drift from the list actually published.

An empty answer keeps its meaning: create still refuses before any chain read, so no
coin is selected, nothing is reserved, and no spend is attempted. That is the money-safe
default and it is what a node with nothing honest to say does.

Blast radius checked

Wiring a previously-empty argument, so the radius is the create path and nothing else.

  • mirror::advertise — new configured_urls; no existing caller, nothing else reads the
    module.
  • NodeMirrorEffects::new — signature unchanged; the advertised_urls field already
    existed and was already threaded to spends::build_create. Its three call sites are
    server.rs:2789 (changed) and two integration probes (unchanged).
  • NodeMirrorEffects::create — the empty-list refusal at lifecycle.rs:381 is unchanged and
    still precedes coin selection.
  • The reclaim path is untouched: reclaims carry no advertisement.

This PR is what makes the dig-node create path reachable. The empty list was, until now,
the thing preventing any create at all, and a security gate on #423 weighed severity against
that. The two guards that matter are present and verified intact after the rebase:
production_broadcaster() as the single seam behind both open_signer and
spawn_mirror_passes, and #423's intra-pass funding-coin reservation, whose RefCell borrow
stays scoped to the selection and is released before signing.

How it was verified

crates/dig-node-service/tests/mirror_advertised_urls.rs — a new integration target, so it
is NOT compiled by cargo test --lib. Both probes drive the REAL composition — environment →
configured_urlsNodeMirrorEffects::create → a signed bundle — over a genuine operator
wallet and genuine CAT funding coins, and read the answer off the broadcast bundle's own
solution bytes rather than off anything reported.

  • the_configured_urls_reach_the_coin_in_the_operators_order — a fixture mixing a loopback
    entry between two publishable ones. Asserts both survivors appear in the coin, that the
    loopback host does not, and that the operator's order is preserved. The fixture lists IPv6
    SECOND on purpose: a fixture already in the recommended order could not distinguish "no
    sort" from "sorts IPv6 first".
  • an_all_rejected_value_refuses_and_spends_nothing — funded deliberately, so a refusal
    cannot be a funding refusal in disguise. Asserts the create refuses, that nothing is
    broadcast, and that zero chain addresses were read.

Mutation-verified, both load-bearing:

mutation result
configured_urls returns Vec::new() order probe FAILS on the create's own refusal
the advertised_urls.is_empty() guard neutered refusal probe FAILS on the chain-read count

The second mutation is why the chain-read assertion exists. The first version of that probe
asserted only that no bundle was broadcast — which dig-mirror-coin's own empty-URL refusal
satisfies identically, so the guard could be moved to AFTER coin selection and the test would
have stayed green while a funding coin was reserved for a create that can never happen,
starving the next bond in the same pass. The measured survival is recorded in the commit.

SPEC

SPEC.md §25.10's PARTIALLY SATISFIED admonition said "nothing reads them yet". The wiring
is what made that false, so it is removed in the same commit. Every remaining clause in the
section was re-checked against code in this diff: the configured-not-derived rule and the
memo publication (advertise.rs module doc, lifecycle.rs:425), absolute-URL and
this-machine rejection (advertise.rs classify), order preserved (parse_advertised_urls),
the warning per rejected entry (configured_urls), and the refusal on an empty list
(lifecycle.rs:381).

Version

0.175.1 → 0.176.0 — minor: a new operator-facing capability, no API removed or changed.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Sequencing note — this PR is what makes dig-node#423's gating defect REACHABLE

Recorded here because it changes what has to land first, and neither PR can see the other.

PR #423's security re-gate found SPEC.md:7965 asserting a MUST NOT the code violates: two creates
in one pass select the same funding coin, because the committed-coin set is read once before the
pass
(server.rs:2762, lifecycle.rs:133) and no create can extend it.

The gate judged it unreachable in production, correctly for the head it read — because
server.rs:2789 passes an empty advertised_urls and lifecycle.rs:343 refuses before any coin
is selected.

This PR is the one that fills that list. The moment it lands, an empty-URL refusal stops guarding
the create path and the double-spend becomes reachable on mainnet.

So: #423's intra-pass reservation fix lands first. That is now in flight — the reservation gains
interior mutability and each create extends it after a successful broadcast, rather than the SPEC
sentence being narrowed to match the defect. Narrowing would have been cheaper and, read literally
against today's head, defensible; it would also have traded a false SPEC clause for a live money
defect on a trigger that was already written and green.

Nothing here needs changing. The work on this PR is sound and its staging is honest — SPEC §25.10
opens with "PARTIALLY SATISFIED at this head" and says plainly that nothing reads the operator
surface yet, which is exactly the right shape for a module that ships before its call site.

When this lane resumes, the remaining half is the wiring into lifecycle.rs / server.rs that
was deliberately deferred while #423 held those files. Rebase with
git rebase --onto origin/main <base-sha>, never plain git rebase origin/main — dig-node
squash-merges, so a plain rebase replays commits against their own squashed form and conflicts in
precisely the files where a bad resolution drops half a seam.

The shape decision on #426 stands as written:
operator-configured, never derived; a list in the operator's own order; and the refusal survives as
the correct default when nothing is configured.

Comment thread crates/dig-node-service/tests/mirror_advertised_urls.rs Fixed
Comment thread crates/dig-node-service/tests/mirror_advertised_urls.rs Fixed
MichaelTaylor3d and others added 7 commits August 30, 2026 11:54
Co-Authored-By: Claude <noreply@anthropic.com>
…426)

A mirror coin publishes where its store can be fetched from, and dig_mirror_coin::create
refuses an advertisement carrying no URL -- so a node with no public name cannot bond.

The value is operator-set and never derived: a coin's URLs are fixed at create for the
epoch, so a self-inferred address that is unreachable from outside, or that changes,
stakes real $DIG on a claim the node cannot keep and SPEC.md 25 penalises.

An unset value means this node advertises nothing and creates nothing, which is the
correct default: publishing an unreachable URL is worse than publishing none.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
#426)

The parser shipped without a consumer: `spawn_mirror_passes` handed
`NodeMirrorEffects` an empty URL list, so every create refused by name and no
mirror coin could ever be made. The scheduler now reads the operator's list once
at bring-up through `advertise::configured_urls`, which also reports each
rejected entry with the reason it was dropped, and passes it to every pass.

An empty answer keeps its meaning: the create still refuses before any chain
read and no spend is attempted, which is the money-safe default SPEC.md 25.10
requires.

SPEC.md 25.10's PARTIALLY SATISFIED admonition is removed in the same commit,
because the wiring is what made it false.

Co-Authored-By: Claude <noreply@anthropic.com>
…mp to 0.176.0

The refusal probe asserted only that no bundle was broadcast, which `dig-mirror-coin`'s
own empty-URL refusal satisfies identically — so a guard moved to after coin selection
would have kept the test green while reserving a funding coin for a create that can
never happen, starving the next bond in the same pass. The fixture chain now counts its
address lookups, and the probe asserts zero: a create that never reads an address is the
only observation that separates the two placements.

Verified by mutation: neutering the guard leaves the outcome assertions green and fails
this one.

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

`ordinary_dig_coins` seeds a grandparent with `[salt; 32]`, so a byte literal reads to
CodeQL as a hard-coded cryptographic value — the same finding dig-node#917 and #950 already
produced, and which the sibling reservation probe already avoids this way. Fixed at the root
rather than dismissed: the salts are now derived from a domain string, so they stay
deterministic and distinct per fixture while ceasing to be literals.

Co-Authored-By: Claude <noreply@anthropic.com>
main reached 0.176.0 with #432 while this branch was in flight, so the branch's own
0.176.0 no longer increments over its base. Rebased onto bd3d701 and re-bumped.

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

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: b3e96faef089a4c195c483dd1f71e50c166f2a8e (resolved from gh pr view 430 --json headRefOid), merge-base bd3d701d24749df3d5e78c8f98febd2b6cc72f76.

Posting as I go so nothing is lost to a stall. No verdict yet.

Settled so far — the four reachability questions

1. The intra-pass reservation is intact and load-bearing at this head. CONFIRMED.

lifecycle.rs is not in this PR's diff (git diff --stat bd3d701..b3e96fa touches Cargo.lock, Cargo.toml, SPEC.md, crates/dig-node-service/Cargo.toml, mirror/advertise.rs, mirror/mod.rs, server.rs, tests/mirror_advertised_urls.rs — nothing else), so #423's fix is present as merged. Verified in the code rather than from the lane's claim:

  • lifecycle.rs:147committed_coin_ids: Result<std::cell::RefCell<std::collections::HashSet<String>>, PassError>.
  • lifecycle.rs:283-287 — extended inside the Ok(_) broadcast arm only, committed.borrow_mut().extend(funding_coin_ids…), i.e. only on a broadcast that reached the mempool.
  • lifecycle.rs:404-411 — the shared borrow is confined to a block expression around select_operator_dig_cats and dropped before signer/build_create/sign_and_broadcast.

No RefCell borrow can be live across an await in this path, and the reason is stronger than scoping: create is a synchronous fn (lifecycle.rs:377) containing no .await at all. The asynchronous readings are taken in server.rs before the pass, and the pass runs under tokio::task::block_in_place (server.rs:2793), so the synchronous body cannot yield mid-borrow.

2. production_broadcaster() is still a SINGLE seam. CONFIRMED.

git grep production_broadcaster at this head returns exactly two non-test, non-doc call sites: lifecycle.rs:567 (the reported capability, via spend_capability(production_broadcaster().is_some())) and server.rs:2806 (the broadcaster the pass is actually handed). One function, two readers. The capability the node announces and the one a spend can reach cannot disagree.

3. The refusal is placed BEFORE any coin selection or chain read. CONFIRMED, by placement and not only by outcome.

lifecycle.rs:381 is the first statement in create after the doc comment — ahead of parse_id (:390), ahead of the committed_coin_ids deref (:393), and ahead of funding::select_operator_dig_cats (:406), which is the chain read. So an empty list cannot reserve a funding coin for a create that can never happen, and cannot starve the next bond in the same pass. This is the specific failure the lane's first probe could not see, and the placement — not the probe — is what rules it out.

4. No memo injection is possible. CONFIRMED at the crate boundary.

dig-mirror-coin 0.7.0 src/create.rs:101-110 builds the memo as a CLVM list of separate atomsmemo_entries.push(Bytes::new(url.as_bytes().to_vec())) per URL, after four fixed leading entries (hint, store, root, epoch). There is no delimiter anywhere in the layout, so a crafted entry cannot forge a field boundary, inject a separator, or read back as a different field: position, not content, determines meaning. Length confusion is likewise unavailable because CLVM atoms are length-prefixed.

Additionally, parse_advertised_urls (advertise.rs:137) splits on ,, space, tab, LF and CR, so an accepted entry can never contain any of those five characters — which independently rules out log injection through the tracing::warn! on advertise.rs:97-103.

Single source of the value — CONFIRMED

git grep at this head shows advertised_urls originates only from advertise::configured_urls() (server.rs:2739) → advertised_urls_from_env() (advertise.rs:72-74) → std::env::var("DIG_MIRROR_ADVERTISE_URLS"). There is no peer-reachable, RPC-reachable or network-reachable path into this list. That fact bounds the severity of everything in the validation section below, which I am still working.

Still open, being worked now

  • The this-machine rejection's completeness under alternate host encodings (empirical probe running).
  • Count/length bounds on the published list.
  • SPEC §25.10 clause-by-clause verification.
  • Privacy of what is published.

One process note, not a finding

gh pr view at this head reports build .msi (windows-x64) still IN_PROGRESS. The lane's own comment states this is non-required and does not gate; I am recording it because the dispatch brief said "all five required checks are SUCCESS", which is true, and "all checks green", which is not yet.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2/2)

Head b3e96faef089a4c195c483dd1f71e50c166f2a8e. Continues my previous comment.

Nothing here is CRITICAL or HIGH. One MEDIUM finding, established by an executed probe, is below.

FINDING (MEDIUM) — classify does not hold the MUST NOT that SPEC 25.10 asserts

crates/dig-node-service/src/mirror/advertise.rs:158-176

SPEC.md 25.10 (added by this diff) states, normatively:

An entry whose host can only mean this machine — loopback, the unspecified address, link-local,
localhost, or dig.localMUST NOT be published.

Two host families reach Advertised::accepted anyway. I reproduced classify verbatim in a standalone crate pinned to url = "=2.5.8" (the version this tree locks) and ran it; the classifier code is copied character-for-character from advertise.rs:158-176, with only crate::config::DIG_LOCAL_HOST inlined as its value dig.local.

A. IPv4-mapped and IPv4-compatible IPv6 literals — any scheme, including https.

ACCEPTED   host=Ipv6(::ffff:127.0.0.1)     "http://[::ffff:127.0.0.1]/"
ACCEPTED   host=Ipv6(::ffff:127.0.0.1)     "http://[::ffff:7f00:1]/"
ACCEPTED   host=Ipv6(::ffff:127.0.0.1)     "http://[0:0:0:0:0:ffff:127.0.0.1]/"
ACCEPTED   host=Ipv6(::7f00:1)             "http://[::127.0.0.1]/"
ACCEPTED   host=Ipv6(::ffff:0.0.0.0)       "http://[::ffff:0.0.0.0]/"
ACCEPTED   host=Ipv6(::ffff:169.254.10.4)  "http://[::ffff:169.254.10.4]/"

Ipv6Addr::is_loopback is true only for ::1, and is_unspecified only for ::. A mapped address carries the loopback, unspecified or link-local meaning in its low 32 bits, where neither predicate nor the fe80::/10 segment test looks. ::ffff:127.0.0.1 resolves to the local machine on every mainstream stack.

B. A bare IPv4 literal under a NON-SPECIAL scheme — including the dig:// form this module is explicitly written to accommodate.

ACCEPTED   host=Domain("127.0.0.1")   "dig://127.0.0.1:4161/"
ACCEPTED   host=Domain("0.0.0.0")     "dig://0.0.0.0/"
ACCEPTED   host=Domain("127.0.0.1")   "foo://127.0.0.1/"

versus, for contrast, the same host under special schemes:

rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)  "http://127.0.0.1:4161/"
rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)  "ws://127.0.0.1/"
rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)  "ftp://127.0.0.1/"

The WHATWG host parser only runs IPv4 parsing for special schemes; a non-special scheme takes the opaque-host path, so dig://127.0.0.1/ arrives as url::Host::Domain("127.0.0.1") and lands in the Domain arm, which tests only localhost, .localhost and dig.local. The Ipv4 arm — which holds the whole loopback/unspecified/link-local rule — is never reached.

This is the guard-specificity shape: the rationale names five concrete behaviours, and the one-off variant of each walks past it. It matters more than an ordinary edge case because any_scheme_is_accepted (advertise.rs:234-237) makes dig:// a tested, intended input, and 25.10 promises the rule applies to it.

Concrete scenario. An operator running the dig:// transport sets DIG_MIRROR_ADVERTISE_URLS=dig://127.0.0.1:4161/ — the exact copy-the-node-own-local-address mistake #426 exists to prevent. configured_urls returns it with an INFO line reading "advertising this node's stores at the operator-configured URLs", and no warning. create (lifecycle.rs:381) sees a non-empty list, selects a funding CAT, and broadcasts. Real $DIG is locked against an advertisement every reader resolves to itself. Because a coin's URLs are fixed at create, this is unrecoverable within the epoch and 25 penalises it — the lane's own "wrong toward publication" column.

Why it is MEDIUM and not HIGH. I traced every writer of this list: advertise::configured_urls (server.rs:2739) is the only producer, and it reads std::env::var on DIG_MIRROR_ADVERTISE_URLS and nothing else. There is no peer-, RPC- or network-reachable path into this value. The affected party is the operator, and the harm is their own staked collateral. It is not a live vulnerability, and no stranger can trigger it.

What the probe also proves works — worth recording, because it is the strength of the decision to reuse a real parser instead of hand-rolling one:

rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)       "http://2130706433/"      decimal
rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)       "http://0x7f000001/"      hex
rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)       "http://017700000001/"    octal
rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)       "http://127.1/"           short form
rejected:ThisMachineOnly  host=Domain("localhost.")  "http://localhost./"      trailing dot
rejected:ThisMachineOnly  host=Domain("dig.local.")  "http://DIG.LOCAL./"      case + dot
rejected:ThisMachineOnly  host=Ipv4(127.0.0.1)       "http://evil@127.0.0.1/"  userinfo
rejected:ThisMachineOnly  host=Domain("localhost")   "dig://localhost:4161/"
rejected:ThisMachineOnly  host=Ipv6(::1)             "dig://[::1]:4161/"
rejected:ThisMachineOnly  host=Domain("dig.local")   "dig://dig.local/"

Every alternate IPv4 encoding I tried is caught, for free, because url normalises them before classify sees them. The gap is narrow and specific, not a general weakness.

Two ways to close it, either acceptable:

  1. Widen classify — unmap before testing (to_ipv4_mapped, plus the ::/96 compatible prefix) and, in the Domain arm, attempt an IpAddr parse of the name before falling through to the name comparisons. Roughly ten lines, plus a fixture pairing a mapped-loopback entry with a surviving public sibling so the test cannot pass by blanket refusal — the shape a_this_machine_host_is_dropped_and_its_public_sibling_survives already uses.
  2. Or narrow SPEC 25.10 to state what the code does. This half costs nothing and, on its own, removes the false money guarantee — so if the create path is judged phase-1 work where the guard widening is loggable, the SPEC clause should still not ship asserting a MUST NOT the code does not hold.

Confirmed accurate — the other 25.10 clauses

Checked against dig-mirror-coin 0.7.0 rather than assumed:

  • "requires at least one and imposes no other rule on them" — TRUE. create.rs:76-80 is the only rule touching urls; the neighbouring zero-collateral check at create.rs:84 is about collateral, not URLs.
  • "the crate's reader accepts any UTF-8 entry" — TRUE. coin.rs:490-497 pushes every entry that survives String::from_utf8 and applies no further filter.
  • "operator-configured and MUST NOT be derived" — TRUE. Single env producer, verified by git grep at this head.
  • "publishes the operator's order verbatim rather than sorting it" — TRUE for order.
  • "When no entry survives, the node advertises nothing and creates no mirror coin" — TRUE, and the refusal is placed ahead of every chain read: lifecycle.rs:381, before parse_id at :390 and before select_operator_dig_cats at :406.

Lower-severity items — DO NOT gate on these

  • 25.10 omits deduplication. advertise.rs:145-148 drops exact duplicates, and an_exact_duplicate_is_published_once at :270 pins it, but the SPEC bullet list — otherwise exhaustive about what happens to an entry — never mentions it, while saying the order is published "verbatim". A reader cannot learn from 25.10 that anything is removed.
  • No bound on entry count or length. parse_advertised_urls accepts arbitrarily many, arbitrarily long entries, each becoming one memo atom (dig-mirror-coin/src/create.rs:106-108). Operator-supplied only, and the failure direction is survivable — an oversized bundle is refused and the money stays put via the error arm at lifecycle.rs:297 — so this is robustness rather than an amplification primitive. Worth a cap anyway.
  • contains_control_character already exists in this crate and is not applied here. config.rs:517-523 documents it as covering "any character that must never survive into a value this node persists", reasoned over the CLASS rather than over one attacker trick. classify does not use it, and the value pushed to accepted is the RAW entry (advertise.rs:147) rather than the parsed URL — so a URL carrying a literal ESC in its path is ACCEPTED (probe line 43) and that byte reaches both the memo and the log calls at advertise.rs:97-117. Comma, space, tab, LF and CR cannot appear because they are separators, so genuine log-line injection is impossible; this is terminal-escape noise plus permanent junk in a memo.
  • is_self_upstream at config.rs:509-513 is a rival, weaker answer to the same is-this-machine question — a string match over dig.local, localhost, 127.0.0.1, 127.0.0.2, with no link-local, no unspecified and no .localhost. advertise::classify is the better implementation. Centralising both behind one predicate would fix both at once. Not a defect in this diff.

Privacy — asked and answered

An advertised URL is a permanent on-chain statement of where this operator's node can be reached, under a puzzle hash tied to their collateral. That is inherent to the feature.

Nothing is published that the operator did not type. accepted holds the raw trimmed entry, so the memo is byte-identical to the configured value — no derived address, no resolver answer and no reflexive candidate is ever added. That is the correct and honest shape, and it follows directly from the configured-never-derived decision.

One consequence worth a sentence at the config surface: URL userinfo is published verbatim too. A URL of the form https://user:PASSWORD@mirror.example/ is ACCEPTED (probe line 26) and its credentials would be written immutably into a memo. No operator would be surprised that their own string is published, but they may not have connected "this string goes on chain forever" with "this string contains a password".

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED (one MEDIUM, doc-level; nothing CRITICAL or HIGH)

Audited head: b3e96faef089a4c195c483dd1f71e50c166f2a8e — resolved from gh pr view 430 --json headRefOid at the start and re-confirmed unchanged immediately before posting. Merge-base bd3d701d24749df3d5e78c8f98febd2b6cc72f76.

No CRITICAL and no HIGH finding. Zero attacker-reachable defects. I traced every producer of every value this diff introduces; none is reachable by a peer, an RPC caller, or anything on a network. The single gating item is a normative SPEC clause that is not true of the code shipped beside it, and it is resolvable either direction in a few lines.

The load-bearing question — is the guard that now carries the weight actually carrying it?

This PR removes the empty-list condition that made #423's double-spend unreachable. All four properties that must hold for that to be safe are CONFIRMED at this head, in the code rather than from the lane's claim:

# Property Verdict Evidence
1 intra-pass reservation intact HOLDS lifecycle.rs:147 RefCell<HashSet<String>>; extended at :283-287 only inside the broadcast-succeeded arm; shared borrow confined to a block around select_operator_dig_cats at :404-411 and dropped before signing
2 no borrow live across an await HOLDS, structurally create is a synchronous fn (lifecycle.rs:377) with no .await in its body; the pass runs under tokio::task::block_in_place (server.rs:2793), so it cannot yield mid-borrow
3 production_broadcaster() a single seam HOLDS git grep at this head returns exactly two non-test call sites: lifecycle.rs:567 (reported capability) and server.rs:2806 (the broadcaster the pass receives)
4 refusal before any coin selection or chain read HOLDS, by placement lifecycle.rs:381 is the first statement of create, ahead of parse_id (:390), the committed_coin_ids deref (:393) and select_operator_dig_cats (:406)

Property 4 is also pinned by a test that can actually see it: an_all_rejected_value_refuses_and_spends_nothing asserts chain.address_lookups == 0 (tests/mirror_advertised_urls.rs:326-334), which is the only observation separating a guard before selection from one after — both broadcast nothing and both return an error. That is the correct assertion and it is not vacuous.

Two rebases did not disturb any of this. git diff --stat bd3d701..b3e96fa touches eight files and mirror/lifecycle.rs is not among them, so #423's fix is present exactly as merged.

Attack surface — what I checked and what would have made it fail

  • Memo injection — NOT POSSIBLE. dig-mirror-coin 0.7.0 src/create.rs:101-110 builds the memo as a CLVM list of separate, length-prefixed atoms, one per URL, after four fixed leading entries. No delimiter exists anywhere in the layout, so no crafted entry can forge a field boundary or read back as a different field. Had the layout been a joined string, a , or NUL in an entry would have been a live forgery of the epoch or root field.
  • Log injection — NOT POSSIBLE. parse_advertised_urls (advertise.rs:137) splits on comma, space, tab, LF and CR, so an accepted entry can never contain any of them. A newline in an entry would otherwise have forged log lines through advertise.rs:97-103.
  • Reachability — NO REMOTE PATH. git grep confirms advertised_urls has exactly one producer: configured_urls() at server.rs:2739, reading std::env::var("DIG_MIRROR_ADVERTISE_URLS"). Any second writer, or any RPC/peer handler that could set it, would have made every item below HIGH.
  • Alternate IPv4 encodings — CAUGHT. Decimal, hex, octal and short-form loopback all normalise inside url before classify sees them, and are rejected. Verified by executed probe, not by reading.
  • Amplification — NOT A PRIMITIVE. The list is unbounded, but it is operator-supplied and the failure direction is survivable: an oversized bundle is refused and the money stays put (lifecycle.rs:297). A peer-supplied path here would have been a HIGH cost-asymmetry finding.
  • Privacy — inherent, not a defect. Nothing is published that the operator did not type; accepted holds the raw trimmed entry, so no derived address, resolver answer or STUN reflexive candidate is ever added. Had the module derived a candidate, that would have been both a privacy leak and the exact staked-on-a-false-claim failure the shape decision exists to prevent.

The one gating finding

MEDIUM — SPEC.md 25.10 asserts a MUST NOT that advertise.rs:158-176 does not hold

Full evidence, including the executed probe output, is in my previous comment. In brief, two host families reach Advertised::accepted despite the clause forbidding them:

  • IPv4-mapped / IPv4-compatible IPv6, under any schemehttp://[::ffff:127.0.0.1]/, http://[::127.0.0.1]/, http://[::ffff:0.0.0.0]/, http://[::ffff:169.254.10.4]/ are all ACCEPTED. Ipv6Addr::is_loopback is true only for ::1 and is_unspecified only for ::; the meaning lives in the low 32 bits, where nothing looks.
  • A bare IPv4 literal under a non-special schemedig://127.0.0.1:4161/ and dig://0.0.0.0/ are ACCEPTED, while http://, ws:// and ftp:// forms of the same host are correctly rejected. A non-special scheme takes the WHATWG opaque-host path, so the value arrives as Host::Domain("127.0.0.1") and the Ipv4 arm holding the entire loopback rule is never reached.

Why this is gating despite not being a live vulnerability. It is not attacker-reachable and I am explicitly not gating on the guard's completeness — that half is defense-in-depth. What I am gating on is that this diff adds a normative money clause which is false of the code in the same diff. SPEC 25.10 tells an operator that a this-machine host cannot be published; an operator on the dig:// transport that any_scheme_is_accepted (advertise.rs:234-237) deliberately supports can publish one, lock real $DIG against it, and be penalised under 25 with no recovery inside the epoch. A SPEC that overstates a money guarantee is the failure this family has now shipped three times.

Either resolution closes it; I have no preference.

  1. Widen classify — unmap before testing (to_ipv4_mapped, plus the ::/96 compatible prefix), and in the Domain arm attempt an IpAddr parse of the name before falling through to the name comparisons. Add a fixture pairing a mapped-loopback entry with a surviving public sibling, so the test cannot pass by blanket refusal — the shape a_this_machine_host_is_dropped_and_its_public_sibling_survives already uses. This makes the clause true.
  2. Or narrow SPEC 25.10 to describe what the code does, and file the widening as a follow-up. This costs one sentence and removes the false guarantee on its own.

Re-gate scope: this leg only. Either fix is confined to advertise.rs and/or SPEC.md and touches nothing in the reservation, the broadcaster seam or the refusal placement, all of which I have verified. Per the gate-tier rule, re-run loop-security on the delta and not the full triple.

Non-gating — file as follow-ups, do not hold the merge

  1. 25.10 omits deduplication. advertise.rs:145-148 drops exact duplicates and an_exact_duplicate_is_published_once (:270) pins it, but the SPEC bullet list never mentions it while calling the published order "verbatim". Cheap to fix in whichever direction the clause above is resolved.
  2. No bound on entry count or per-entry length. Each entry becomes one memo atom (dig-mirror-coin/src/create.rs:106-108). Operator-supplied and fails safe, but a cap is worth having before this path is ever fed from anywhere else.
  3. contains_control_character (config.rs:517-523) already exists and is not applied here. Its own doc says it covers "any character that must never survive into a value this node persists", reasoned over the class rather than one trick. accepted stores the RAW entry (advertise.rs:147), so a literal ESC in a URL path is accepted and reaches both the permanent memo and the operator log. Not injection — the five separator characters are structurally excluded — but permanent junk on chain.
  4. is_self_upstream (config.rs:509-513) is a rival, weaker answer to the same question: a string match over dig.local, localhost, 127.0.0.1, 127.0.0.2, with no link-local, unspecified or .localhost. Centralising both behind one predicate fixes both at once and stops them drifting further.
  5. Document at the config surface that URL userinfo is published verbatimhttps://user:PASSWORD@mirror.example/ is accepted and its credentials would be written immutably into a memo.

Not re-reported

#424 (production_broadcaster() is None), #433 (bond-states chain_unreadable), #411 (vacuous spend-record writer), and the env-only read-back gap are known and were excluded per the dispatch brief. Separately: dig-mirror-coin's reader dropping a non-UTF-8 memo entry is the crate's stated behaviour and affects readers of other nodes' coins, not this node's writing.

Process notes

  • build .msi (windows-x64) was IN_PROGRESS, not green, throughout this audit. All five required checks are SUCCESS, so this does not gate — recording it only because the brief said "all five required checks are SUCCESS", which is true, while "all checks green" is not yet.
  • No shared checkout was mutated. I read every file from git objects (git show <sha>:<path>, git diff <base>..<head>, git cat-file) and ran only git fetch origin in the dig-node primary checkout, which touches remote refs and no working-tree file. The probe was built as a standalone crate in my own session scratchpad with url = "=2.5.8" pinned to match this tree's lock; no dig-node source was compiled, copied into, or edited.
  • PR left DRAFT. I merged nothing, undrafted nothing, and edited no code.

…its spelling

SPEC.md 25.10 requires that an entry whose host can only mean this machine is
never published, but `classify` read that rule off the host's SPELLING, so two
whole classes reached a mirror coin's memos:

* An IPv4-mapped or IPv4-compatible IPv6 address under any scheme.
  `Ipv6Addr::is_loopback` is true only of `::1`; the meaning of `[::ffff:127.0.0.1]`
  lives in its low 32 bits and nothing looked there.
* A bare IPv4 literal under a NON-SPECIAL scheme. `dig://` is an intended input,
  and a non-special scheme takes the WHATWG opaque-host path, so `127.0.0.1`
  arrived as `Host::Domain` and the arm holding the entire loopback rule never ran.

The second is the dangerous one: an operator who sets
`DIG_MIRROR_ADVERTISE_URLS=dig://127.0.0.1:4161/` gets no warning and an INFO
line saying the node is advertising, while `create` locks real $DIG against an
address every reader resolves to itself — unrecoverable within the epoch,
because a coin's URLs are fixed at create.

The v4 rule now has ONE home, `is_this_machine_only_v4`, which all three paths
that can yield a v4 address funnel through, so no two can drift. The v6 arm asks
its own predicates BEFORE unwrapping an embedded v4, because `::1` unwraps to the
ordinary global `0.0.0.1`.

A private or LAN address stays publishable, however it is written: this closes
"can only mean this machine", not "is not public".

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

Copy link
Copy Markdown
Contributor Author

Gating finding closed: the this-machine rule is now on the address DENOTED, not on its spelling

Fixed in the code, at 970bff7. SPEC.md §25.10 was not narrowed.

crates/dig-node-service/src/mirror/advertise.rs

  • The v4 half of the rule now has ONE home, is_this_machine_only_v4. All three paths that can
    yield a v4 address funnel through it — the Host::Ipv4 arm, a non-special scheme's opaque host,
    and an IPv4-mapped/-compatible IPv6 address — so no two can drift into disagreeing. No second copy
    of the rule was written.
  • Class 2 (opaque host). Host::Domain(s) is now parsed as an Ipv4Addr first; on success it
    goes through the same rule. dig://127.0.0.1:4161/ and dig://0.0.0.0/ are refused;
    dig://node.example/ still passes, so any_scheme_is_accepted is untouched.
  • Class 1 (wrapped v6). Chose to_ipv4(), not to_ipv4_mapped(): it also covers the
    deprecated IPv4-COMPATIBLE form (::127.0.0.1), which the probe found and which is written by
    hand as readily as the mapped one. A compatible address means exactly its embedded v4, so the
    wider unwrap never answers a question the narrower one should have declined.
  • Ordering is load-bearing, not stylistic. The v6 predicates are asked BEFORE the embedded-v4
    unwrap, because ::1 unwraps to 0.0.0.1 — an ordinary global v4 address. Asking the v4 rule
    first would have ACCEPTED the IPv6 loopback, so the widening had a way to overshoot into a
    regression and a control pins it.
  • Alternate v4 encodings are still left to url's normalisation, as you said. A private/LAN address
    is still published, under any spelling.

Red/green, proving each fixture load-bearing. The two new fixtures were run against the
unmodified classify and failed on their own assertions, with the accepted list showing exactly
the entries the probe found:

a_non_special_scheme_does_not_smuggle_a_this_machine_host_past_the_opaque_host_path ... FAILED
  accepted: ["dig://127.0.0.1:4161/", "dig://0.0.0.0/", "dig://node.example/"]  rejected: []
an_ipv4_wrapped_in_ipv6_is_judged_by_the_address_it_embeds ... FAILED
  accepted: ["http://[::ffff:127.0.0.1]/", "http://[::127.0.0.1]/", "http://[::ffff:0.0.0.0]/",
             "http://[::ffff:169.254.10.4]/", "http://[2001:db8::1]/"]  rejected: []

After the fix, cargo test -p dig-node-service --lib mirror::advertise is 10 passed / 0 failed,
and the integration target — cargo test -p dig-node-service --test mirror_advertised_urls, which
--lib does not compile — is 2 passed / 0 failed.

Every accepted-but-shouldn't-be case from your probe is a fixture, both classes, with the
non-special scheme named in the test name. Controls that must still be accepted: an ordinary public
host, http://192.168.1.10/, and http://[::ffff:192.168.1.10]/.

SPEC.md §25.10 widened to describe the code rather than the reverse — SPEC.md:8376-8381:

The rule is on the address the host DENOTES, not on how it is written: it MUST hold under every
scheme, including a non-special one whose host is opaque, and an IPv6 address that embeds an IPv4
one MUST be judged by the address it embeds.

Verified against advertise.rs classify (the Host::Domain v4 re-parse and the to_ipv4() arm)
and is_this_machine_only_v4. No fourth born-false clause.

Not touched, as instructed: lifecycle.rs is not in this diff. The five follow-ups you filed
(§25.10 dedup, list bound, unused contains_control_character, the is_self_upstream rival,
userinfo) are untouched here.

PR stays DRAFT. Re-gate scope is this leg only.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 30, 2026 19:55
@MichaelTaylor3d
MichaelTaylor3d merged commit 6d6bb7c into main Aug 30, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/426-advertised-url branch August 30, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mirror creates need an advertised URL this node can be fetched at

2 participants