Repository navigation
fix(id/peers): escape announced names at the printers so a gossip name cannot forge TSV rows (#1613) - #1798
Conversation
…e cannot forge TSV rows (#1613) A peer's announced name arrives over unsigned gossip, is postcard-decoded with no validation, stored verbatim by PeerDiscovery::update and served verbatim by ListPeers. All three tab-separated printers interpolated it raw, so a name with LF forged extra peer rows (including a fully-formed fake node row) and a name with TAB shifted every following column. Pin the contract at the printers, as #1611's triage pinned it for the claim/doc/group plain-text output: stored and served announcements stay verbatim, and no ingest validation is added (a store may already hold a sync-ingested value ingest cannot retroactively clean). Add one shared helper, commands::text::plain_field, used by all three sites (the two REPL sites share format_peer_line). A value with no structure-breaking character is returned byte-identically; a value that has one is rendered as a double-quoted JSON string literal with the offending characters escaped — the same convention #1611's renderer uses. The trigger set is any control character (covers LF, CR, TAB, NUL, DEL, NEL, ...) plus U+2028/U+2029. #1611's key=value renderer also quotes on an ASCII space, which breaks its tokenisation; a space does not delimit a TSV field and ordinary names with spaces must display unchanged, so space stays raw here. Mechanism and escaping are shared; only the trigger set is narrowed to what can break a tab-separated row. Tests: helper unit tests; format_peer_row / format_peer_line tests for LF, CR, TAB, control-only and forged names and for ordinary (spaces, Unicode) names; and a round-trip test that drives forged names through postcard gossip ingest -> PeerDiscovery::update -> the ListPeers response -> the wire -> both printers, asserting one row and four fields per peer while the served names stay verbatim. cargo test --lib (default features): 1488 passed, 1 flaky log_sync cap test that passes on rerun; cargo fmt --check clean.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 30 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Problem
id peersand both REPL peer printers interpolate a peer's announcednameraw into a tab-separated, one-row-per-peer row. The name comes from an unsignedPeerAnnouncementbroadcast on a public gossip topic: it is postcard-decoded with no validation, stored verbatim byPeerDiscovery::update, and served verbatim byListPeers. A name containing LF forges extra peer rows (including a fully-formed fake node row); a name containing TAB shifts every following column.Print sites fixed:
pkgs/id/src/commands/peers.rs—cmd_peersrowpkgs/id/src/commands/repl.rs— REPLpeerspkgs/id/src/commands/repl.rs— REPLpeers_on_nodeRuling (acceptance 3)
The rendering contract is pinned at the printers, not at ingest or serving. Stored and served announcements stay byte-for-byte verbatim (
ListPeersis unchanged, no ingest validation added): a store may already hold a sync-ingested value that ingest validation could not retroactively clean, and protocol data is data. The printers never let a name break row structure. This is the same ruling theid claim/id doc/id groupplain-text renderer fix (#1611) pinned.The escaping scheme (acceptance 2)
One shared helper,
plain_fieldin the newpkgs/id/src/commands/text.rs, is used by all three sites (the two REPL sites shareformat_peer_line, which calls it):\n,\r,\t,\u00XX) — the same convention id(claim/doc/group): plain-text output renders names/values raw — a newline in a tag name or value forges extra rows and duplicate author=/event= fields #1611's renderer uses, so a consumer that needs the original bytes can parse the field as JSON.Trigger set, stated in the helper's docs: any control character (this covers
LF,CR,TAB, NUL, DEL, NEL, ...) and the Unicode line/paragraph separatorsU+2028/U+2029.One deliberate difference from #1611's
key=valuerenderer, called out here because the two schemes must not drift silently: #1611 also quotes on an ASCII space (a space breaks itskey=valuetokenisation), while this helper does not — a space is not a TSV field delimiter, and the issue's acceptance requires ordinary names with spaces to display unchanged. The mechanism and the escaping convention are identical; only the trigger set is narrowed to what can break a tab-separated row.Cross-links: #1611 (sibling renderer ruling), and the #1613 triage ruling on the issue (task 1723) which pinned the contract at the renderer.
Tests (acceptance 1, 4)
commands/text.rs: unit tests pin the rule at the helper — byte-identical for space-free and space-bearing ordinary names, quoted/escaped for LF, CR, TAB, other controls andU+2028/U+2029, no raw structure byte in the output, forged payload cannot add a row.commands/peers.rs:format_peer_rowtests (one row, four fields, raw name never reaches the row; ordinary names unchanged;-when the name is absent) plus a round-trip test: forged names go through postcard gossip ingest ->PeerDiscovery::update->ListPeersresponse (built exactly asprotocol.rsbuilds it) -> postcard wire -> both printers; asserts one row per peer per printer, four fields per row, and that stored/served names are still verbatim.commands/repl.rs:format_peer_linetests for the same names (it is the single helper behind both REPL sites).Verification
cargo test --lib commands::-> 99 passed, 0 failed.cargo test --lib(full default-features suite) -> 1487 passed, 1 failed:log_sync::tests::test_batch_collection_refused_stream_ends_at_the_total_cap(203 vs 256), unrelated and flaky — it passes when rerun alone.cargo test --doc commands::text-> passed.cargo fmt --check-> clean.cargo test --all-featurescannot compile on this laptop: thewebfeature needs built web assets (src/web/assets.rsRustEmbed) andsrc/web/mod.rsfails withAssets::get/Assets::iternot found. Pre-existing environment limitation, unrelated to this change; tests were run with default features.Closes #1613