Task
client_name arrives off the wire and is composed verbatim into the operator's pairing-approval
prompt, with an unmarked truncation and no character neutralisation. This is the same shape as a
CRITICAL just fixed in dig-app (PR #265), one repo over.
Measured
Ingest — crates/dig-node-service/src/pairing.rs:124-133, on the OPEN (unauthenticated)
pairing.request:
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("unknown controller")
.chars()
.take(MAX_CLIENT_NAME) // = 64, pairing.rs:80 — UNMARKED truncation
.collect();
Display — crates/dig-node-service/src/pair.rs:94 composes it into the approval prompt the operator
reads before granting a control token:
out.push_str(&format!(
" • {} code {} \"{}\"\n approve: dig-node pair approve {}\n",
…, p["client_name"].as_str().unwrap_or("controller"), …));
Also at pair.rs:51 and :108 (the issued-token list).
So an attacker-chosen string is composed into the sentence that gates a privileged grant.
Why this matters here specifically
pairing.request is OPEN — the whole point of the code-confirm step is that an unauthenticated caller
cannot pair without an operator's consent. The operator's only evidence about who is asking is
client_name.
Three attacks, all demonstrated on the dig-app equivalent:
- Unmarked truncation.
.take(64) cuts silently. Pad with characters that consume budget while
rendering as nothing and the node's own truncation produces a short, trusted-looking name. The
wallet did exactly this to itself in dig-app — the software does the forging.
- Zero-width / format characters. U+200B–200F, U+061C, U+2060, U+FEFF are neither
is_control()
nor whitespace, so str::trim does not touch them and they survive into the display while consuming
the 64-char budget.
- Composition. Newlines and bidi overrides let the value break out of its
"{}" slot and forge
additional lines in the app's own voice — the prompt is line-oriented and the value is quoted, not
escaped.
The severity is lower than dig-app's — this is a CLI prompt an operator reads at a terminal, not a GUI
signing window, and a \n is more visible in a bulleted list. But the grant it gates is a control
token, so it is worth fixing properly rather than dismissing on presentation grounds.
Scope
Apply the same three rules dig-app landed (now recorded in the canonical skill):
- Mark the clip IN-BAND, never as a separate flag a caller can drop — that dropped
bool was the
dig-app CRITICAL.
- Charge the budget on rendered width, not code points, and strip Cf characters and bidi overrides
— not just is_control().
- Neutralise what is DISPLAYED, never what is stored or compared. If
client_name is ever used as an
identity or matched against, that value stays byte-verbatim; only the rendering is neutralised.
Reuse rather than re-derive. dig-app's helper solves exactly this; if it can be lifted to a shared
crate, prefer that over a fourth implementation — dig-app had three rivals of this helper, and the
disagreement between them was the finding.
Evidence
- Prove the negative: a
client_name of 64 zero-width chars followed by a trusted-looking name must
not render as that trusted name; a multi-line value must not produce extra lines.
- A test asserting only length and absence of newlines is SATISFIED BY THE ATTACK — that exact shape
is why the dig-app bug shipped green. Assert what the output says, not merely that a bound held.
- Read passed-counts, never
ok. Any mutation must assert its patch applied, and parse the
test result: line rather than stderr.
Cross-references
Same class, fixed: DIG-Network/dig-app PR #265 (eco#1499) — CRITICAL, remotely reachable with no
pairing. Rules recorded in the canonical skill, commit 38efebe.
Task
client_namearrives off the wire and is composed verbatim into the operator's pairing-approvalprompt, with an unmarked truncation and no character neutralisation. This is the same shape as a
CRITICAL just fixed in dig-app (PR #265), one repo over.
Measured
Ingest —
crates/dig-node-service/src/pairing.rs:124-133, on the OPEN (unauthenticated)pairing.request:Display —
crates/dig-node-service/src/pair.rs:94composes it into the approval prompt the operatorreads before granting a control token:
Also at
pair.rs:51and:108(the issued-token list).So an attacker-chosen string is composed into the sentence that gates a privileged grant.
Why this matters here specifically
pairing.requestis OPEN — the whole point of the code-confirm step is that an unauthenticated callercannot pair without an operator's consent. The operator's only evidence about who is asking is
client_name.Three attacks, all demonstrated on the dig-app equivalent:
.take(64)cuts silently. Pad with characters that consume budget whilerendering as nothing and the node's own truncation produces a short, trusted-looking name. The
wallet did exactly this to itself in dig-app — the software does the forging.
is_control()nor whitespace, so
str::trimdoes not touch them and they survive into the display while consumingthe 64-char budget.
"{}"slot and forgeadditional lines in the app's own voice — the prompt is line-oriented and the value is quoted, not
escaped.
The severity is lower than dig-app's — this is a CLI prompt an operator reads at a terminal, not a GUI
signing window, and a
\nis more visible in a bulleted list. But the grant it gates is a controltoken, so it is worth fixing properly rather than dismissing on presentation grounds.
Scope
Apply the same three rules dig-app landed (now recorded in the
canonicalskill):boolwas thedig-app CRITICAL.
— not just
is_control().client_nameis ever used as anidentity or matched against, that value stays byte-verbatim; only the rendering is neutralised.
Reuse rather than re-derive. dig-app's helper solves exactly this; if it can be lifted to a shared
crate, prefer that over a fourth implementation — dig-app had three rivals of this helper, and the
disagreement between them was the finding.
Evidence
client_nameof 64 zero-width chars followed by a trusted-looking name mustnot render as that trusted name; a multi-line value must not produce extra lines.
is why the dig-app bug shipped green. Assert what the output says, not merely that a bound held.
ok. Any mutation must assert its patch applied, and parse thetest result:line rather than stderr.Cross-references
Same class, fixed:
DIG-Network/dig-appPR #265 (eco#1499) — CRITICAL, remotely reachable with nopairing. Rules recorded in the
canonicalskill, commit38efebe.