fix(types)!: reject malformed SignedOrder JSON at deserialize time#237
Open
prestwich wants to merge 2 commits into
Open
fix(types)!: reject malformed SignedOrder JSON at deserialize time#237prestwich wants to merge 2 commits into
prestwich wants to merge 2 commits into
Conversation
`SignedOrder::order_hash()` previously panicked on a `SignedOrder` deserialized from JSON when the `signature` field was not exactly 65 bytes, via `Signature::from_raw(...).unwrap()`. The same type is used for both ABI-decoded chain events (where the 65-byte invariant is enforced by the contract) and serde-decoded JSON (where any byte string is accepted), so the unwrap was unsound at the off-chain trust boundary. This change: - Adds `signet_zenith::serde_helpers` with `deserialize_signature_bytes` and `deserialize_non_empty_vec` for use as `#[serde(deserialize_with)]` on sol-shaped types received at JSON boundaries. - Replaces `SignedOrder`'s derived `Deserialize` with a custom impl backed by private repr types that enforce a 65-byte signature, a non-empty `permitted` list, and a non-empty `outputs` list. Malformed inputs now surface as `serde_json::Error`. - Renames `SignedOrder::new` to `new_unchecked` (the structural invariants are now documented on the constructor). - Renames `SignedOrder::validate` to `validate_at` and adds `is_valid_at`, clarifying that this checks time/state validity only. - Documents the `order_hash` `.unwrap()` as sound under the type-level invariant, with a `// SAFETY:` comment. - Adds proptests in `signet-test-utils` proving the deserialize path never panics on arbitrary input. - Adds `// SAFETY:` annotations to two related audit findings in `agg/fill.rs` and `agg/order.rs` whose unwraps/casts are sound under caller-side or protocol invariants. Refs ENG-2288. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaking changes in this release: - `SignedOrder::new` renamed to `new_unchecked` - `SignedOrder::validate` renamed to `validate_at` - `<SignedOrder as Deserialize>` now rejects malformed signatures, empty `permitted`, and empty `outputs` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fraser999
requested changes
May 21, 2026
Contributor
Fraser999
left a comment
There was a problem hiding this comment.
One blocker on the issue in checked_remove_aggregate.
| } | ||
|
|
||
| #[test] | ||
| fn non_empty_vec_rejected() { |
Contributor
There was a problem hiding this comment.
Suggested change
| fn non_empty_vec_rejected() { | |
| fn empty_vec_rejected() { |
Comment on lines
+215
to
+221
| // SAFETY: the `check_aggregate` call above proves, for every | ||
| // `(output_asset, recipient)` pair in `aggregate`, that the entry | ||
| // exists in `self.fills` and that `filled >= amount`. We hold | ||
| // `&mut self` for the duration, so neither the map nor the | ||
| // recipient balances can change between the check and the | ||
| // mutation. The `get_mut`/`unwrap` and `checked_sub`/`unwrap` | ||
| // below are therefore infallible by construction. |
Contributor
There was a problem hiding this comment.
Claude has spotted that this isn't really true. The following snippet probably explains the edge case best:
let mut context = AggregateFills::default();
let asset = Address::with_last_byte(1);
let present_recipient = Address::with_last_byte(99);
let missing_recipient = Address::with_last_byte(2);
// The outer `(chain, asset)` key exists in `self.fills`, but only `present_recipient` is in the inner map.
context.add_raw_fill(1, asset, present_recipient, U256::from(100));
// Zero-amount output to a recipient that is absent from `self.fills[(1, asset)]`.
// `check_aggregate` returns `Ok` here.
let mut aggregate = AggregateOrders::new();
aggregate.ingest_raw_output(1, asset, missing_recipient, U256::ZERO);
// Sanity check: the prior check the SAFETY comment leans on is currently a no-op for this input.
context.check_aggregate(&aggregate).unwrap();
// The unwrap inside `checked_remove_aggregate` panics here.
let _ = context.checked_remove_aggregate(&aggregate);We probably want to tighten check_aggregate to fail for this case (and add a regression test)?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SignedOrder::order_hash()panicked on aSignedOrderdeserialized from untrusted JSON whosesignaturewas not exactly 65 bytes — an asymmetric DoS vector against any consumer of the tx-cache order feed.permitted, non-emptyoutputs) to type-system guarantees enforced at deserialize time, soorder_hash'sSignature::from_raw(...).unwrap()becomes sound by construction.signet_zenith::serde_helpersexposesdeserialize_signature_bytesanddeserialize_non_empty_vecfor other sol-shaped types that need the same treatment at JSON boundaries.SignedOrder::new→new_unchecked(documenting the invariants the caller must uphold) andSignedOrder::validate→validate_at, plus a newis_valid_at, drawing a clear line between structural validity (enforced at construction) and time/state validity (checked at call sites).signet-test-utils/tests/deserialization_fuzz.rsprove theSignedOrderdeserialize path is total — every input either deserializes into a value satisfying all three invariants, or errors via serde; never panics.// SAFETY:annotations on two audit findings (agg/fill.rs:209,agg/order.rs) whose unwraps/casts are sound under caller-side or protocol invariants.Breaking changes
SignedOrder::new(...)→SignedOrder::new_unchecked(...)SignedOrder::validate(...)→SignedOrder::validate_at(...)<SignedOrder as Deserialize>now rejects inputs that previously deserialized successfully (i.e. the bug).Minor-version bump recommended on next release.
Reporter
This bug was reported externally by Cinder Circuit / @achimala under the Signet bug bounty.
Test plan
cargo t -p signet-types -p signet-zenith -p signet-orders -p signet-test-utils— all passcargo clippy --workspace --all-targets --all-features -- -D warningscargo clippy --workspace --all-targets --no-default-features -- -D warningsRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depscargo +nightly fmt -- --check#[should_panic]to asserting a serdeErrSignedOrdertotality, round-trip stability, garbage-string safety🤖 Generated with Claude Code
Fixes ENG-2288