Skip to content

gl prints node-advertised strings to the terminal unsanitized (Cf bidi/Trojan Source, raw-print sibling of #183) #189

Description

@beardthelion

Summary

Across gl, node HTTP-response fields are extracted (value["field"].as_str()) and passed straight to println!/bail! with no sanitizer. A Cf bidi/format control (e.g. U+202E RLO) in an attacker-influenceable node field therefore reaches the operator's terminal and can visually reorder the line (Trojan Source, CWE-451), and a raw Cc escape (e.g. ESC) is equally unstripped.

This is the raw-print sibling of #183. #183 hardened the two error-body sanitizer functions; these sites have no sanitizer at all, so the is_control/sanitize-shaped grep used for #183 never surfaced them.

Affected (node field -> terminal, no sanitizer)

  • crates/gl/src/status.rs — node version (:71), open-PR title (:98), open-issue title (:125), all via println!.
  • crates/gl/src/bounty.rs — bounty-list title (:251, println!); node message on create/show/cancel failure (:197, :267, :377, bail!); cmd_show prints the entire node body pretty-printed (:270), which carries title/description fields raw.
  • crates/gl/src/agent.rs — agent model and capabilities (:105, :107, println!); node message on list/show failure (:76, :136, bail!).
  • crates/gl/src/quickstart.rs — node message on registration / repo-creation failure (:122, :208, println!).

(bounty.rs:202 echoes the caller's own --title, not node data, so it is excluded.)

None of these call gl::sync::sanitize_node_msg, which is the existing helper that strips !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(c) and caps length (INV-6).

Reachability

The bytes are attacker-influenceable: a hostile or MITM'd node populates title / message / model / version in its JSON response, and they render to the operator's TTY during ordinary gl status / gl bounty / gl agent / gl quickstart use. No auth or special state required beyond talking to the node.

Reproduction (executed)

Driving the real bounty::cmd_show against a mock node returning {"message":"not<U+202E>found"} yields a terminal-bound error string of bounty not found: not<U+202E>found — the override survives. Separately, serde_json parse plus the exact ["title"].as_str() extraction the println! sites use preserves the U+202E it is handed. Both confirmed by test against the current tree.

Fix

Route every node-supplied string through a terminal sanitizer before printing. The pieces already exist from #183: the shared predicate gitlawb_core::sanitize::is_bidi_format and the sanitize_node_msg shape (strip Cc + the Cf reordering subset, bound length). Options, in order of preference:

  1. Promote a small sanitize_node_msg-style helper to a shared location (it currently lives in gl::sync) and call it at each print site.
  2. Or inline the !is_control() && !is_bidi_format() filter at each site.

Strip only the bidi-reordering subset (as #183 does); legitimate Cf such as ZWJ must pass through. Add a per-site (or per-command) test that a U+202E in the relevant node field does not survive to the printed/bailed string, mirroring the #183 tests.

Scope

In scope: the gl raw-print sites above. Out of scope: homoglyph and other Trojan-Source vectors beyond bidi reordering; node-side changes.

Severity

Same class and blast radius as #183: terminal spoofing/phishing, not a leak or RCE, with a known localized fix. Suggested sev:medium, kind:security, crate:gl.

Follow-up to #183 (PR #188).

Metadata

Metadata

Assignees

No one assigned

    Labels

    crate:glgl — the contributor CLIkind:securityVulnerability fix or hardeningsev:mediumDegraded but workaround existssubsystem:peersPeer announce, discovery, and registry

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions