feat(relay): Add a serialization crate for bounded deserializers - #6280
feat(relay): Add a serialization crate for bounded deserializers#6280klochek wants to merge 4 commits into
Conversation
| deserialize_seq(), | ||
| deserialize_map(), | ||
| deserialize_identifier(), | ||
| deserialize_ignored_any(), |
There was a problem hiding this comment.
Bug: The deserialization budget does not account for operations spent on ignored/unknown fields, allowing an attacker to bypass the max_ops limit and cause a DoS with a large, nested unknown field.
Severity: HIGH
Suggested Fix
Ensure that skipping ignored data is also metered. This could be achieved by deserializing the ignored value into a temporary structure like serde_json::Value and then counting its operations, or by implementing a custom visitor that recursively consumes and counts the elements within the ignored data instead of using the unmetered deserialize_ignored_any function.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: relay-serialization/src/serde/de.rs#L213
Potential issue: The deserialization metering logic is intended to prevent DoS attacks
by limiting the number of operations via `max_ops`. However, when deserializing a struct
that does not use `#[serde(deny_unknown_fields)]`, any unknown fields are processed by
`deserialize_ignored_any()`. This function in the underlying `serde_json` deserializer
skips parsing the field's value without triggering the metered visitor methods. As a
result, an attacker can craft a payload with a large, deeply nested JSON object in an
unknown field, causing significant parsing work that is not counted against the
`max_ops` budget, thus circumventing the intended DoS protection.
Did we get this right? 👍 / 👎 to inform future reviews.
| // The payload charges itself, an `Option` only adds its discriminant on top. | ||
| self.inner | ||
| .visit_some(MeteredDeserializer::new(self.meter, d)) | ||
| } |
There was a problem hiding this comment.
Option Some skips discriminant cost
Low Severity
visit_some comments that an Option adds its discriminant cost on top of the payload, but never calls meter.spend. visit_none does charge, so Some values are under-counted relative to the stated model and to None.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8b0c917. Configure here.
Dav1dde
left a comment
There was a problem hiding this comment.
Awesome!
Some future (not fully thought through) ideas, we can provide "safe" APIs for JSON and Message pack in this crate. Default the work the a reasonable constant for all of Relay, only users which need more can explicitly overwrite it.
Ban all of serde_json from other crates (e.g. Annoated is such a candidate).
| @@ -0,0 +1,19 @@ | |||
| [package] | |||
| name = "relay-serialization" | |||
There was a problem hiding this comment.
The new crate needs to also be referenced here: https://getsentry.github.io/relay/relay/#workspace-crates
| relay-replays = { workspace = true } | ||
| relay-conventions = { workspace = true } | ||
| relay-sampling = { workspace = true } | ||
| relay-serialization = { workspace = true } |
There was a problem hiding this comment.
Not actually used yet, did you add it here on purpose?
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5015df2. Configure here.
| deserialize_seq(), | ||
| deserialize_map(), | ||
| deserialize_identifier(), | ||
| deserialize_ignored_any(), |
There was a problem hiding this comment.
Ignored fields bypass op budget
High Severity
deserialize_ignored_any is forwarded to the inner deserializer, so formats like serde_json can syntactically skip unknown-field content without walking it through MeteredVisitor. A large nested payload in an undeclared field then costs only one unit, which breaks the operation budget for untrusted input. Relay schemas typically allow unknown fields, so this is an easy bypass.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5015df2. Configure here.
| deserialize_option(), | ||
| deserialize_unit(), | ||
| deserialize_seq(), | ||
| deserialize_map(), |
There was a problem hiding this comment.
Zero-cost structural visitors allow unbounded recursion to bypass the operation budget
deserialize_option and deserialize_newtype_struct in the forward macro delegate to visit_some and visit_newtype_struct, which charge zero operations. An attacker can nest wrappers arbitrarily deep for almost no budget cost, causing the inner deserializer to recurse until the native stack overflows.
Evidence
forward!includesdeserialize_option()(line ~211) anddeserialize_newtype_struct()(line ~219), passing them to the inner deserializer with aMeteredVisitor.- The corresponding
MeteredVisitor::visit_someandvisit_newtype_structmethods (outside hunk) do not callmeter.spend; they treat the wrapper as transparent and charge only the inner payload. - In formats like bincode or msgpack, an attacker can craft a deeply nested
Option<Option<…None…>>chain; the inner deserializer recurses once per nesting level while the meter is consumed by only the final leaf value. - There is no depth limit or recursion guard, so a small payload causes an uncatchable stack-overflow abort.
Also found at 1 additional location
relay-serialization/src/serde/mod.rs:5
Identified by Warden · wrdn-dos-review · EWA-4KF
|
|
||
| mod de; | ||
|
|
||
| pub use de::Error; |
There was a problem hiding this comment.
MeteredVisitor charges one unit for arbitrarily large strings and byte buffers
visit_string, visit_byte_buf, and sibling methods charge a single operation after the inner deserializer has already materialized the value, so a huge string or byte buffer bypasses the operation budget and can trigger an allocator abort / OOM.
Evidence
relay-serialization/src/serde/de.rslines 302–312 definevisit_string,visit_bytes,visit_borrowed_bytes, andvisit_byte_buf, each callingself.meter.spend(cost::UNIT)?after theStringorVec<u8>is already fully allocated.cost::UNITis defined as1, so a multi-megabyte buffer costs exactly one operation; the meter counts values, not bytes, and the check is enforced after the allocation is paid.- Relay’s existing event schema code already enforces
max_byteslimits (e.g.,relay-event-normalization/src/trimming.rsandrelay-event-schema/src/protocol/replay.rs), confirming this dimension is a known requirement that the new bounded deserializer omits.
Also found at 1 additional location
relay-serialization/src/serde/de.rs:534
Identified by Warden · wrdn-dos-review · X9C-Q25
| deserialize_seq(), | ||
| deserialize_map(), | ||
| deserialize_identifier(), | ||
| deserialize_ignored_any(), |
There was a problem hiding this comment.
MeteredDeserializer under-counts memory: huge allocations bypass the operation budget
The meter counts deserialized values, not bytes. A single multi-megabyte string, byte buffer, or collection with a huge declared length costs one operation but can trigger unbounded pre-allocation in the inner deserializer before the budget is consulted.
Evidence
MeteredVisitor::visit_string(de.rs:~280) andvisit_byte_buf(de.rs:~296) charge onlycost::UNIT(1 op) after the inner deserializer has already allocated the fullString/Vec<u8>.MeteredSeqAccess::size_hint()(de.rs:323) returns the inner hint unchanged, soVec::deserializecan callVec::with_capacity(hint)on an attacker-declared length before any element is metered.- The operation cap (
max_ops) under-counts true memory cost because it bounds the number of values, not bytes — one operation can drive an unbounded allocation. - The crate is newly introduced and not yet wired to production request paths, so the attack surface is latent rather than immediate.
Also found at 1 additional location
relay-serialization/src/serde/de.rs:371-373
Identified by Warden · wrdn-dos-review · 868-FTK


No description provided.