Skip to content

sec(pairing): client_name is composed verbatim into the operator approval prompt with an unmarked truncation #346

Description

@MichaelTaylor3d

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

Ingestcrates/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();

Displaycrates/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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions