Skip to content

feat(relay): Add a serialization crate for bounded deserializers - #6280

Open
klochek wants to merge 4 commits into
masterfrom
christopherklochek/relay_serializer_serde
Open

feat(relay): Add a serialization crate for bounded deserializers#6280
klochek wants to merge 4 commits into
masterfrom
christopherklochek/relay_serializer_serde

Conversation

@klochek

@klochek klochek commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@klochek
klochek requested a review from a team as a code owner July 31, 2026 19:06
deserialize_seq(),
deserialize_map(),
deserialize_identifier(),
deserialize_ignored_any(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread relay-serialization/src/serde/de.rs
// The payload charges itself, an `Option` only adds its discriminant on top.
self.inner
.visit_some(MeteredDeserializer::new(self.meter, d))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8b0c917. Configure here.

Comment thread relay-serialization/src/serde/de.rs

@Dav1dde Dav1dde left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new crate needs to also be referenced here: https://getsentry.github.io/relay/relay/#workspace-crates

Comment thread relay-server/Cargo.toml Outdated
relay-replays = { workspace = true }
relay-conventions = { workspace = true }
relay-sampling = { workspace = true }
relay-serialization = { workspace = true }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not actually used yet, did you add it here on purpose?

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5015df2. Configure here.

deserialize_option(),
deserialize_unit(),
deserialize_seq(),
deserialize_map(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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! includes deserialize_option() (line ~211) and deserialize_newtype_struct() (line ~219), passing them to the inner deserializer with a MeteredVisitor.
  • The corresponding MeteredVisitor::visit_some and visit_newtype_struct methods (outside hunk) do not call meter.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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.rs lines 302–312 define visit_string, visit_bytes, visit_borrowed_bytes, and visit_byte_buf, each calling self.meter.spend(cost::UNIT)? after the String or Vec<u8> is already fully allocated.
  • cost::UNIT is defined as 1, 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_bytes limits (e.g., relay-event-normalization/src/trimming.rs and relay-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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) and visit_byte_buf (de.rs:~296) charge only cost::UNIT (1 op) after the inner deserializer has already allocated the full String/Vec<u8>.
  • MeteredSeqAccess::size_hint() (de.rs:323) returns the inner hint unchanged, so Vec::deserialize can call Vec::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

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.

2 participants