Skip to content

fix(error)!: hold relay-supplied text as SafeText, bump dig-nat to 0.15 - #51

Merged
MichaelTaylor3d merged 1 commit into
mainfrom
harden/1883-safetext-peer-error-text
Jul 31, 2026
Merged

fix(error)!: hold relay-supplied text as SafeText, bump dig-nat to 0.15#51
MichaelTaylor3d merged 1 commit into
mainfrom
harden/1883-safetext-peer-error-text

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes DIG-Network/dig_ecosystem#1883. Part of the dig-nat 0.15 release-first cascade (DIG-Network/dig_ecosystem#1674 / #1675).

The defect, and where it actually was

The ticket named five GossipError variants as String. Three of them are not: PeerNotConnected, PeerBanned and DuplicateConnection carry a PeerId, which is a chia_protocol::Bytes32 — a fixed 32 bytes rendered as hex. A peer cannot put text of its choosing there, so no forged peer id containing a newline exists to inject and those three are already unrepresentable. They are left as PeerId on purpose: widening them to a text type would invite a future caller to interpolate a formatted string into a field that currently cannot hold one.

The two String variants were real, and the type change surfaced a live instance the ticket had not found:

  • nat/discovery.rsRelayMessage::Error { code, message } interpolated message raw. That field is a String deserialized straight out of an explicitly untrusted relay's JSON, so a hostile relay chose its bytes freely. Proven live over a real loopback WebSocket before the fix:
    "relay error: relay error 7: denied\n2026-07-31T00:00:00Z ERROR peer 0000 is trusted\u{202e}\0"
    
  • relay/relay_client.rshandle_register_ack formatted a relay-supplied RegisterAck message the same way.

The fix

The variants whose text comes from a remote party (RelayError, ConnectionFiltered, NatError) hold dig_nat::SafeText, new in dig-nat 0.15.0. Its only doors are from_untrusted (escapes control + bidi characters, bounds the length) and from_static(&'static str), which a runtime String cannot pass through — so raw peer text becomes unrepresentable rather than guarded. That is the point: this defect was reopened once per variant by callers individually forgetting a convention, so a sanitiser you must remember to call is the wrong shape of fix.

SafeText's Debug renders like its Display, which matters here because several call sites use {:?} and a fix wired only to Display would regress them silently. Relay failures in nat::discovery now funnel through one relay_failure helper, so there is a single place to read to know no relay-chosen byte reaches a rendered error.

IoError / InvalidConfig / AddressManagerStore / IntroducerError keep String — their text comes from the OS, the local config, or this crate's own literals. Recorded at the enum with the reasoning, so the next reader does not have to re-derive which variants are in the class.

How it was verified

tests/sec_1883_peer_text_tests.rs (3 tests, new):

  1. Real wire, and a placement discriminator. A hostile message carrying a real newline, an RLO bidi override (category Cf, which char::is_control misses) and a NUL travels a real loopback WebSocket relay speaking the RLY-005 wire. The assertion is on the error value the moment relay_get_peers returns it, before anything logs it — so a fix applied at a logging site rather than at the wire boundary leaves that value dirty and fails.
  2. Every converted variant, not just the one call site that was broken.
  3. The PeerId variants, pinning why they stay a fixed-width id.

Every assertion checks Display and Debug. Every test carries a control so the fix cannot be satisfied by dropping detail: the error must still name the subsystem, carry the relay's status code, and show the escaped reason. Both directions were confirmed rather than assumed:

  • Red for the right reason — pre-fix, the real-wire test failed on the raw newline quoted above.
  • The controls are load-bearing — replacing the sanitising helper with one that discards the detail (SafeText::from_static("relay query failed")) fails the control (the error must still carry the relay's status code), even though it satisfies every newline assertion.

Gates: cargo fmt --check clean, cargo clippy --all-targets --locked -- -D warnings clean, cargo test --locked green across the suite.

Pre-existing failures, NOT from this change (baseline-verified): tests/con_1691_reconnect_tests.rs fails 4 of 5 on a pristine origin/main worktree as well — peer-map newest-wins supersede semantics, untouched here. Separately, the tracked Cargo.lock on origin/main records dig-gossip 0.17.19 while Cargo.toml says 0.17.21, so cargo test --locked fails on main outright; this branch's lock is re-resolved and correct. Both are being reported for their own tickets.

Blast radius checked

Per the standing override, blast radius via ripgrep + a direct read of every construction and match site rather than gitnexus. GossipError::{RelayError,ConnectionFiltered,NatError} has 30 references across src/error.rs, src/nat/discovery.rs, src/relay/relay_client.rs, src/service/gossip_handle.rs and 5 test files; the compiler enumerated all 14 production/test breakages after the type change and each was migrated (.as_str() at match sites that bound the payload). PeerId was traced to chia_protocol::Bytes32 to establish that the other three variants needed no change. Risk: breaking for consumers — the payload type changes and the dig-nat re-pin rotates the identity of the dig-nat types re-exported from src/nat, hence the MINOR on 0.x.

Dependency + version

dig-nat = "0.14""0.15". The tracked lock resolves dig-nat 0.15.0 and dig-tls 0.3.0 from registry+https://github.com/rust-lang/crates.io-index, one copy of each. The pre-existing [patch.crates-io] in this manifest covers only the vendored chia-protocol / chia-sdk-client / native-tls; it does not touch the cascade crates, and it was not modified here.

0.17.210.18.0 (MINOR = breaking on 0.x): the variant payload types and the re-exported dig-nat type identities are both public-API changes.

Docs kept coherent in the same unit

docs/resources/SPEC.md §4 and docs/requirements/domains/crate_api/specs/API-004.md both declared RelayError(String) normatively, including an acceptance checkbox asserting it "contains a String". Both now state SafeText with the reason, and the API-004 variant table and its test_relay_error_display row are updated to match.

Consumers must pin

dig-gossip is not on crates.io (DIG-Network/dig_ecosystem#900 / #1872), so this version bump publishes nothing — consumers take it by git rev. A consumer moving to this commit must simultaneously be on dig-nat 0.15, since this crate re-exports dig-nat types from src/nat and a second dig-nat in the tree is now an E0308. Note that a git-rev consumer does not inherit this manifest's [patch.crates-io].

The untrusted relay's `RelayMessage::Error { message }` was interpolated raw
into `GossipError::RelayError`, so a newline the relay chose forged a whole
second line in any log rendering the error. A second live instance sat in
`RelayClient::handle_register_ack`, which formatted a relay-supplied RegisterAck
`message` the same way.

Rather than sanitize at each render site — the approach this defect already
defeated once per variant — the variants whose text comes from a remote party
now hold `dig_nat::SafeText` (new in dig-nat 0.15.0), whose only doors are
`from_untrusted` and `from_static(&'static str)`. A runtime `String` cannot pass
through either, so raw peer text is unrepresentable rather than guarded, and
`SafeText`'s `Debug` renders like its `Display` because `{:?}` reaches a log at
least as often as `{}` does. Relay failures in `nat::discovery` funnel through
one `relay_failure` helper so there is a single place to read to know this holds.

Two deliberate non-changes, both recorded at the enum:

* `PeerNotConnected` / `PeerBanned` / `DuplicateConnection` were reported as
  `String` but hold a `PeerId` — a fixed 32-byte `Bytes32` rendered as hex. A
  peer cannot put text of its choosing there, so they are already
  unrepresentable, and widening them to a text type would invite a future caller
  to interpolate a formatted string into a field that currently cannot hold one.
* `IoError` / `InvalidConfig` / `AddressManagerStore` / `IntroducerError` keep
  `String`: their text comes from the OS, the local config, or this crate's own
  literals, never from a stranger.

`tests/sec_1883_peer_text_tests.rs` drives a hostile `message` — a real newline,
a bidi override and a NUL — through a real loopback WebSocket relay and asserts
on the error the moment `relay_get_peers` returns it, before anything logs it,
so a fix placed at a logging site instead of the wire boundary fails it. Each
assertion covers `Display` and `Debug`, and each test carries a control
demanding the error still name the subsystem, the relay's status code, and the
escaped reason — verified load-bearing by confirming that a variant which drops
the detail entirely fails those controls.

The dig-nat re-pin is mandatory rather than cosmetic: a 0.x MINOR is
semver-incompatible, so `"0.14"` can never resolve 0.15.0, and `SafeText` now
crosses dig-dht's and dig-peer's public error surfaces, making a tree with two
dig-nat versions an outright E0308 on those seams.

BREAKING CHANGE: `GossipError::RelayError`, `ConnectionFiltered` and `NatError`
carry `dig_nat::SafeText` instead of `String`; match arms binding the payload
need `.as_str()`. The `dig-nat` re-pin also rotates the identity of the dig-nat
types this crate re-exports from `src/nat`.

Refs DIG-Network/dig_ecosystem#1883, DIG-Network/dig_ecosystem#1674

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the harden/1883-safetext-peer-error-text branch from 2e20b3e to f0ff72d Compare July 31, 2026 15:33
@MichaelTaylor3d MichaelTaylor3d changed the title fix(error): hold peer-supplied text as SafeText, bump dig-nat to 0.15 fix(error)!: hold relay-supplied text as SafeText, bump dig-nat to 0.15 Jul 31, 2026
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 31, 2026 15:35
@MichaelTaylor3d
MichaelTaylor3d merged commit b7ec3d1 into main Jul 31, 2026
9 of 10 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the harden/1883-safetext-peer-error-text branch July 31, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant