Skip to content

interchange, repr: Prevent deep Protobuf and jsonb stack overflows - #37582

Closed
def- wants to merge 7 commits into
MaterializeInc:mainfrom
def-:pr-sql-515
Closed

interchange, repr: Prevent deep Protobuf and jsonb stack overflows#37582
def- wants to merge 7 commits into
MaterializeInc:mainfrom
def-:pr-sql-515

Conversation

@def-

@def- def- commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Motivation

Materialize recursively processes Protobuf schemas when deriving source relation types and when Prost decodes wire-nested descriptor messages or unknown groups. Deep but inexpensive inputs can exhaust the environmentd stack and abort the process during CREATE SOURCE. Deeply nested jsonb values can likewise exhaust the stack during SQL type validation or serialization.

A fixed limit on Protobuf message reference chains is not upgrade-safe. Materialize can re-render sources whose catalogs already contain schemas beyond a newly introduced limit. Those schemas must remain decodable.

After this change, existing deeply nested Protobuf schemas continue to work during upgrades and re-renders, deeply nested jsonb values no longer crash Materialize in these paths, and pathological wire-nested descriptor sets produce an error instead of aborting the process.

Description

  • Grow the stack on demand while deriving relation types from Protobuf message reference chains. This preserves support for schemas accepted and persisted by earlier versions.
  • Grow the stack during recursive jsonb type validation and serialization.
  • Scan a Protobuf FileDescriptorSet iteratively before DescriptorPool::decode and reject message or group wire nesting deeper than 128 levels.
  • Make the wire scan schema-aware so it descends only into message-typed fields and groups. Strings, bytes, scalars, and unknown length-delimited fields remain opaque, matching Prost decoding and avoiding false rejections.

Verification

Adds Protobuf regression coverage for deep non-cyclic message reference chains, deeply wire-nested descriptor messages, deeply nested unknown groups, and valid option strings containing message-like bytes. Also adds a regression test for type-checking deeply nested jsonb values without overflowing the stack.

Closes: SS-342

@def-
def- marked this pull request as ready for review July 10, 2026 22:08
@def-
def- requested a review from a team as a code owner July 10, 2026 22:08
@def-
def- requested a review from martykulma July 13, 2026 08:01

@martykulma martykulma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems this has some upgrade impact. My understanding is that we would re-render CREATE SOURCE from the catalog, which may already contain protobuf schema with a depth > 128.

@def-

def- commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Ouch, thanks for catching that. Edit: fixed

@def-
def- requested a review from martykulma July 14, 2026 08:09
@def- def- changed the title interchange: Bound protobuf message nesting depth to avoid stack overflow interchange, repr: Prevent deep Protobuf and jsonb stack overflows Jul 14, 2026
def- and others added 6 commits July 16, 2026 08:38
…flow

`derive_inner_type` recurses once per message when deriving a source's
relation type from a Protobuf `FileDescriptorSet`. It guarded against
cyclic message types with a `seen_messages` name set, but not against a
deep *non-cyclic* chain (`m0 -> m1 -> ... -> mN`). A descriptor set is a
flat list of messages that reference each other by name, so such a chain
encodes cheaply and, at `CREATE SOURCE ... FORMAT PROTOBUF` plan time,
overflowed the coordinator stack and aborted environmentd.

Bound the nesting depth (the `seen_messages` length is the current depth)
and return a graceful error past the limit instead of recursing further.

Closes: SQL-515

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `jsonb` value is a single column type that can hold arbitrarily deep
nesting, built at runtime (e.g. via `WITH MUTUALLY RECURSIVE` +
`jsonb_build_array`). Two recursive walks over such a value ran on the
environmentd/pgwire thread with no stack management and overflowed:

* `Datum::is_instance_of_sql` (the type-check applied to a value before it
  is returned), and
* `JsonbDatum::serialize` (pgwire/text output encoding).

Wrap both in `mz_ore::stack::maybe_grow` so a deep value grows the stack
rather than aborting the process. Regression test in scalar.rs (STACK-8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The message-nesting guard added for SQL-515 runs in `derive_inner_type`,
after `DescriptorPool::decode` has already decoded the whole
`FileDescriptorSet`. That decode is itself recursive: `DescriptorProto`
nests via `nested_type` and unknown group fields are skipped recursively,
and the workspace builds Prost with `no-recursion-limit`, so a deeply
nested (but cheap to encode) descriptor set overflows the stack during
decode, before the guard runs. This is reachable from user input via
`FORMAT PROTOBUF MESSAGE ... USING SCHEMA '<bytes>'`.

Add an iterative wire-format pre-scan in `from_bytes` that rejects input
nested deeper than a limit before decoding. It walks the wire with an
explicit stack so it cannot overflow, and descends into a superset of
what Prost recurses into, so any input that would drive the decoder past
the limit is rejected first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wire pre-scan guarding `DescriptorPool::decode` against stack overflow
descended into every length-delimited field, treating opaque string and
bytes payloads as nested messages. An option string of `0x4b` bytes reads
as a chain of `StartGroup` keys, so the scan rejected valid, shallow
descriptors once such a string passed the depth limit. This broke normal
CSR compilation and `FORMAT PROTOBUF ... USING SCHEMA`, and could panic a
storage worker re-rendering an inline descriptor persisted by an older
version.

Track the `descriptor.proto` message type of each wire region and descend
only into genuinely message-typed fields, mirroring the decoder's
recursion. Strings, bytes, and unknown fields are skipped as opaque
leaves. Groups still count toward the limit, so both overflow vectors
(`nested_type` chains and nested groups) stay bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deriving a relation type from a deep Protobuf message chain now succeeds,
so everything downstream that recurses over the resulting nested record
type must also survive on a fixed stack. Grow the stack on demand in
SqlScalarType/ReprScalarType clone, drop, serde, and proto conversions
(manual impls, with serde(remote) mirrors to keep the wire format
unchanged), and in the SQL<->repr type conversions.

Prost's generated encode/decode recursion cannot be instrumented per
level, so SourceData::{encode,decode}_schema and deeply nested message
decoding in the Protobuf Decoder run on a single large stack via the new
mz_ore::stack::grow.

A type with a Drop impl cannot be destructured by value or
const-promoted, so adjust the affected match and static sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@def-
def- requested review from a team as code owners July 16, 2026 14:52
Deriving a relation type from a deep Protobuf message chain now succeeds,
so the comparison, hashing, and formatting traits that recurse over the
resulting nested type must survive on a fixed stack too. Give SqlScalarType
manual PartialEq/Eq/Ord/PartialOrd/Hash/Debug impls and ReprScalarType a
manual Debug impl, and grow the stack on demand in their recursive arms,
along with ReprScalarType's existing PartialEq/Ord/Hash and Display.

Downstream types that merely contain these enums (SqlColumnType,
SqlRelationType, RelationDesc) keep their derived impls, since the recursion
passes through one of the guarded methods exactly once per nesting level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@martykulma martykulma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR is getting really hard to reason about! I've been trying to get through it for the better part of this afternoon, but not done.

I get the sense this is turning into whack-a-mole. Having to hand-roll PartialEq, Hash, etc. Means you'll solve for known cases today, but future cases may run into issues. I'm not even sure if all cases are covered. I got as far as SqlScalarType::eq_inner, and it looks like paths that reach it may not be protected (not sure yet).

@def-

def- commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Indeed, once when stack overflow is fixed, the next one pops up. First I wanted to just prevent deep protobufs, but that seems tough if someone is using them already, especially in self-managed

@def-
def- marked this pull request as draft July 20, 2026 21:19
@ggevay

ggevay commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

I'll get back to this as part of the Stack Overflow project. There, I have a different approach for solving stack overflows in derived things like PartialEq: https://linear.app/materializeinc/project/address-stack-overflows-4676f0d5c40d/activity#project-update-599094b4

@def- def- closed this Jul 30, 2026
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.

3 participants