Task
A stranger can determine whether this operator enabled onion relaying, by draining a rate-limit
bucket and watching a different RPC method change shape. Close the side channel, or decide
deliberately that the opt-in is not a secret and record that.
Why the equal-frames guard does not cover this
relay_capsule's five refusal sites all return one payload-free RelayStatus::Refused, and
dig-node#356's new guard pins the refusal FRAME equal to a plain miss. That closes the frame
channel. It does not close this one, because the leak is not in the frame — it is in a state
change the refusal leaves behind.
The mechanism
relay_capsule gate 3 (module_relay.rs:124) calls allow_proxy_fetch(requestor).
allow_proxy_fetch (download.rs:1643) CONSUMES a token, not merely inspects one.
- That bucket is shared with
miss_outcome's second leg (download.rs:2721), where exhaustion
degrades a later read from fetch-through to redirect.
So the probe is:
- Spam
dig.getModuleInfo with proxy: true to drain the bucket. Burst is 4.0
(rate_limit.rs:179).
- Then issue
dig.getContent and watch which shape comes back.
- Bytes means relaying is OFF — gate 2 refused before gate 3, so nothing was ever consumed.
- Redirect means relaying is ON — gate 3 was reached and the tokens were spent.
The operator's opt-in is inferable from ~5 cheap requests, and nothing in the relay path looks
anomalous while it happens.
Why gate ORDER is the root cause
Gate 2 (operator opt-in, :120) returns BEFORE gate 3 (:124) ever runs. That ordering is what makes
token consumption a proxy for the opt-in: the presence or absence of a side effect answers a question
the frames were carefully built not to answer.
A related, smaller ordering defect sits beside it: :124 also runs before the capsule-warmer check at
:127, so on a build with no warmer a requestor's allowance is spent on a relay that could never have
happened. Same class, lower severity.
The fork, so whoever takes this does not have to invent it
- (a) Do not consume on a path that cannot relay. Check-then-consume, or refund when a later gate
refuses. Removes the signal; costs a small amount of limiter accuracy under abuse.
- (b) Separate the buckets so the proxy-class allowance is not observable through
miss_outcome.
Removes the cross-method coupling that makes the probe cheap; more moving parts.
- (c) Decide the opt-in is not a secret and say so in
module_relay.rs's module doc — deleting
the confidentiality claim rather than leaving a claim the code does not honour. Legitimate, and
strictly better than the current state, where the doc implies a property the system does not have.
(a) is the recommendation. It is the smallest change and it addresses the root — a refusal should
not leave evidence of how far it got.
Evidence bar
A guard here must distinguish "the bucket was charged" from "the bucket was not", so it needs two
runs that differ ONLY in the operator opt-in, with the post-state asserted unequal. Asserting the
refusal frames are equal is already done by #356's guard and is not sufficient — that is the
whole point of this ticket.
Provenance
Found by the pre-merge security audit on PR #504 (the dig-node#356 NC-12 doc-claim audit), which
was asked whether the equal-frames guard actually closed the oracle and demonstrated that it does
not. Not fixed there: #504 is a measurement PR, and a security change grown inside it would make it
ungateable.
The correctness of #504's guard is unaffected — it closes the frame channel it claims to close,
and its doc now names this channel explicitly rather than implying the oracle is shut.
Audit: #356
Parent: https://github.com/DIG-Network/dig_ecosystem/issues/3128
Task
A stranger can determine whether this operator enabled onion relaying, by draining a rate-limit
bucket and watching a different RPC method change shape. Close the side channel, or decide
deliberately that the opt-in is not a secret and record that.
Why the equal-frames guard does not cover this
relay_capsule's five refusal sites all return one payload-freeRelayStatus::Refused, anddig-node#356's new guard pins the refusal FRAME equal to a plain miss. That closes the framechannel. It does not close this one, because the leak is not in the frame — it is in a state
change the refusal leaves behind.
The mechanism
relay_capsulegate 3 (module_relay.rs:124) callsallow_proxy_fetch(requestor).allow_proxy_fetch(download.rs:1643) CONSUMES a token, not merely inspects one.miss_outcome's second leg (download.rs:2721), where exhaustiondegrades a later read from fetch-through to redirect.
So the probe is:
dig.getModuleInfowithproxy: trueto drain the bucket. Burst is 4.0(
rate_limit.rs:179).dig.getContentand watch which shape comes back.The operator's opt-in is inferable from ~5 cheap requests, and nothing in the relay path looks
anomalous while it happens.
Why gate ORDER is the root cause
Gate 2 (operator opt-in,
:120) returns BEFORE gate 3 (:124) ever runs. That ordering is what makestoken consumption a proxy for the opt-in: the presence or absence of a side effect answers a question
the frames were carefully built not to answer.
A related, smaller ordering defect sits beside it:
:124also runs before the capsule-warmer check at:127, so on a build with no warmer a requestor's allowance is spent on a relay that could never havehappened. Same class, lower severity.
The fork, so whoever takes this does not have to invent it
refuses. Removes the signal; costs a small amount of limiter accuracy under abuse.
miss_outcome.Removes the cross-method coupling that makes the probe cheap; more moving parts.
module_relay.rs's module doc — deletingthe confidentiality claim rather than leaving a claim the code does not honour. Legitimate, and
strictly better than the current state, where the doc implies a property the system does not have.
(a) is the recommendation. It is the smallest change and it addresses the root — a refusal should
not leave evidence of how far it got.
Evidence bar
A guard here must distinguish "the bucket was charged" from "the bucket was not", so it needs two
runs that differ ONLY in the operator opt-in, with the post-state asserted unequal. Asserting the
refusal frames are equal is already done by
#356's guard and is not sufficient — that is thewhole point of this ticket.
Provenance
Found by the pre-merge security audit on PR #504 (the
dig-node#356NC-12 doc-claim audit), whichwas asked whether the equal-frames guard actually closed the oracle and demonstrated that it does
not. Not fixed there: #504 is a measurement PR, and a security change grown inside it would make it
ungateable.
The correctness of
#504's guard is unaffected — it closes the frame channel it claims to close,and its doc now names this channel explicitly rather than implying the oracle is shut.
Audit: #356
Parent: https://github.com/DIG-Network/dig_ecosystem/issues/3128