Skip to content

[ObjC] Carry recursion depth across MessageSet item parsing - #27571

Closed
tonghuaroot wants to merge 1 commit into
protocolbuffers:mainfrom
tonghuaroot:harden/objc-messageset-recursion-depth
Closed

tonghuaroot wants to merge 1 commit into
protocolbuffers:mainfrom
tonghuaroot:harden/objc-messageset-recursion-depth

Conversation

@tonghuaroot

Copy link
Copy Markdown

Summary

GPBMessage::parseMessageSet: reads each MessageSet item's payload bytes into a separate buffer (because the type-id and payload tags can appear in any order), then allocates a fresh GPBCodedInputStream to decode that buffer. The fresh stream's state_.recursionDepth starts at 0 even when the parent stream is already several MessageSet items deep, so a chain of nested MessageSet items grows the native call stack without ever tripping the documented kDefaultRecursionLimit (100). Other parser entry points that recurse on the same stream (readGroup:, readMessage:, readMapEntry:, SkipToEndGroupInternal) already increment and check recursionDepth; the MessageSet path was the only spot where depth tracking did not cross the stream boundary.

This change adds a package-private -initWithData:parentRecursionDepth: initializer to GPBCodedInputStream that seeds the child stream's depth from the parent's and runs CheckRecursionLimit before parsing begins. parseMessageSet: now uses that initializer, so depth tracking carries across the fresh stream. This mirrors the depth-inheritance done by the C++ ParseContext spawn helper.

Regression coverage: testParseMessageSetRecursionDepthCarriedFromParent in GPBWireFormatTests builds a 200-deep MessageSet-of-MessageSet payload (each layer is MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...) and asserts the parse fails with GPBCodedInputStreamErrorRecursionDepthExceeded. Existing testErrorRecursionDepthReached and the rest of GPBWireFormatTests continue to pass unchanged.

Test plan

  • Existing ObjC test suite passes (GPBWireFormatTests, GPBMessageTests+Serialization, GPBCodedInputStreamTests, GPBUnknownFieldsTest).
  • New testParseMessageSetRecursionDepthCarriedFromParent passes.
  • I was unable to run the full ObjC test suite locally (no full Xcode install on the dev machine, only Command Line Tools). The runtime sources (GPBCodedInputStream.m, GPBMessage.m) compile cleanly via clang -fno-objc-arc. Please verify via the project's macOS CI.

@tonghuaroot
tonghuaroot requested a review from a team as a code owner May 25, 2026 01:39
@tonghuaroot
tonghuaroot requested review from dmaclach and removed request for a team May 25, 2026 01:39
GPBMessage::parseMessageSet: allocates a fresh GPBCodedInputStream for
each MessageSet item's payload because the type-id and payload tags are
read out of order. The new stream started at recursionDepth=0 even when
the parent stream was already several MessageSet items deep, so a chain
of nested MessageSet items could grow the native call stack arbitrarily
without ever tripping the documented kDefaultRecursionLimit of 100.

Add a -initWithData:parentRecursionDepth: initializer that seeds the
child stream's depth from the parent and checks the limit before parsing
begins. Use it in parseMessageSet: so MessageSet item parsing inherits
the parent's depth, matching the depth-inheritance convention used by
the C++ ParseContext spawn helper.

The existing testErrorRecursionDepthReached covers ordinary nested
message recursion via a single stream; this change keeps that path
unchanged and adds testParseMessageSetRecursionDepthCarriedFromParent
to GPBWireFormatTests covering the cross-stream case.

Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>
@tonyliaoss
tonyliaoss force-pushed the harden/objc-messageset-recursion-depth branch from c950e14 to 6969693 Compare July 7, 2026 17:03
@tonyliaoss tonyliaoss added the 🅰️ safe for tests Mark a commit as safe to run presubmits over label Jul 7, 2026
@github-actions github-actions Bot removed the 🅰️ safe for tests Mark a commit as safe to run presubmits over label Jul 7, 2026
@tonyliaoss
tonyliaoss requested a review from thomasvl July 7, 2026 18:34

@thomasvl thomasvl 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.

Apologies for the delay in looking.

// which increments and checks the depth before recursing into a payload
// that has been read into a fresh buffer.
state_.recursionDepth = parentDepth + 1;
CheckRecursionLimit(&state_);

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.

I think we've got a leak here. If this goes over the budget, this will throw, That means self doesn't get returned and nothing does the release. So this flow needs to be revised to avoid that leak case. Either by doing the release here (some way), or deferring where the check is done to some place that allows the initialization to happen first.

// kDefaultRecursionLimit (100) to actually apply. A depth well past 100
// must be rejected with GPBCodedInputStreamErrorRecursionDepthExceeded
// rather than silently parsed.
const NSUInteger kDepth = 200;

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's really easy to get the recursion calculations slightly wrong, so there should be a test at 100 (kDefaultRecusionLimit), that passes; and the one at 101 (default + 1) that fails, to ensure the depth is being computed correctly.

copybara-service Bot pushed a commit that referenced this pull request Aug 4, 2026
## Summary

`GPBMessage::parseMessageSet:` reads each MessageSet item's payload bytes into
a separate buffer (because the type-id and payload tags can appear in any
order), then allocates a fresh `GPBCodedInputStream` to decode that buffer. The
fresh stream's `state_.recursionDepth` starts at `0` even when the parent
stream is already several MessageSet items deep, so a chain of nested
MessageSet items grows the native call stack without ever tripping the
documented `kDefaultRecursionLimit` (100). Other parser entry points that
recurse on the same stream (`readGroup:`, `readMessage:`, `readMapEntry:`,
`SkipToEndGroupInternal`) already increment and check `recursionDepth`; the
MessageSet path was the only spot where depth tracking did not cross the stream
boundary.

This change adds a package-private `-initWithData:parentRecursionDepth:`
initializer to `GPBCodedInputStream` that seeds the child stream's depth from
the parent's and runs `CheckRecursionLimit` before parsing begins. To prevent
memory leaks if `CheckRecursionLimit` raises an exception during
initialization, the check is wrapped in a `@try/@catch` block that releases
`self`. `parseMessageSet:` uses that initializer, so depth tracking carries
across the fresh stream. This mirrors the depth-inheritance done by the C++
`ParseContext` spawn helper.

Regression coverage: `testParseMessageSetRecursionDepthCarriedFromParent` in
`GPBWireFormatTests` builds MessageSet-of-MessageSet payloads (each layer is
`MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...`, adding
2 to the recursion depth per layer). It verifies that 50 layers (depth 100,
`kDefaultRecursionLimit`) parses successfully, while 51 layers (depth 101,
`kDefaultRecursionLimit + 1`) fails with
`GPBCodedInputStreamErrorRecursionDepthExceeded`. Existing
`testErrorRecursionDepthReached` and the rest of `GPBWireFormatTests` continue
to pass unchanged.

## Test plan

- Existing ObjC test suite passes (`GPBWireFormatTests`,
  `GPBMessageTests+Serialization`, `GPBCodedInputStreamTests`,
  `GPBUnknownFieldsTest`).
- New `testParseMessageSetRecursionDepthCarriedFromParent` passes for both 50
  layers (passing at kDefaultRecursionLimit) and 51 layers (failing at
  kDefaultRecursionLimit + 1).

Closes #27571

PiperOrigin-RevId: 957331709
copybara-service Bot pushed a commit that referenced this pull request Aug 4, 2026
## Summary

`GPBMessage::parseMessageSet:` reads each MessageSet item's payload bytes into
a separate buffer (because the type-id and payload tags can appear in any
order), then allocates a fresh `GPBCodedInputStream` to decode that buffer. The
fresh stream's `state_.recursionDepth` starts at `0` even when the parent
stream is already several MessageSet items deep, so a chain of nested
MessageSet items grows the native call stack without ever tripping the
documented `kDefaultRecursionLimit` (100). Other parser entry points that
recurse on the same stream (`readGroup:`, `readMessage:`, `readMapEntry:`,
`SkipToEndGroupInternal`) already increment and check `recursionDepth`; the
MessageSet path was the only spot where depth tracking did not cross the stream
boundary.

This change adds a package-private `-initWithData:parentRecursionDepth:`
initializer to `GPBCodedInputStream` that seeds the child stream's depth from
the parent's and runs `CheckRecursionLimit` before parsing begins. To prevent
memory leaks if `CheckRecursionLimit` raises an exception during
initialization, the check is wrapped in a `@try/@catch` block that releases
`self`. `parseMessageSet:` uses that initializer, so depth tracking carries
across the fresh stream. This mirrors the depth-inheritance done by the C++
`ParseContext` spawn helper.

Regression coverage: `testParseMessageSetRecursionDepthCarriedFromParent` in
`GPBWireFormatTests` builds MessageSet-of-MessageSet payloads (each layer is
`MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...`, adding
2 to the recursion depth per layer). It verifies that 50 layers (depth 100,
`kDefaultRecursionLimit`) parses successfully, while 51 layers (depth 101,
`kDefaultRecursionLimit + 1`) fails with
`GPBCodedInputStreamErrorRecursionDepthExceeded`. Existing
`testErrorRecursionDepthReached` and the rest of `GPBWireFormatTests` continue
to pass unchanged.

## Test plan

- Existing ObjC test suite passes (`GPBWireFormatTests`,
  `GPBMessageTests+Serialization`, `GPBCodedInputStreamTests`,
  `GPBUnknownFieldsTest`).
- New `testParseMessageSetRecursionDepthCarriedFromParent` passes for both 50
  layers (passing at kDefaultRecursionLimit) and 51 layers (failing at
  kDefaultRecursionLimit + 1).

Closes #27571

PiperOrigin-RevId: 957331709
copybara-service Bot pushed a commit that referenced this pull request Aug 4, 2026
## Summary

`GPBMessage::parseMessageSet:` reads each MessageSet item's payload bytes into
a separate buffer (because the type-id and payload tags can appear in any
order), then allocates a fresh `GPBCodedInputStream` to decode that buffer. The
fresh stream's `state_.recursionDepth` starts at `0` even when the parent
stream is already several MessageSet items deep, so a chain of nested
MessageSet items grows the native call stack without ever tripping the
documented `kDefaultRecursionLimit` (100). Other parser entry points that
recurse on the same stream (`readGroup:`, `readMessage:`, `readMapEntry:`,
`SkipToEndGroupInternal`) already increment and check `recursionDepth`; the
MessageSet path was the only spot where depth tracking did not cross the stream
boundary.

This change adds a package-private `-initWithData:parentRecursionDepth:`
initializer to `GPBCodedInputStream` that seeds the child stream's depth from
the parent's and runs `CheckRecursionLimit` before parsing begins. To prevent
memory leaks if `CheckRecursionLimit` raises an exception during
initialization, the check is wrapped in a `@try/@catch` block that releases
`self`. `parseMessageSet:` uses that initializer, so depth tracking carries
across the fresh stream. This mirrors the depth-inheritance done by the C++
`ParseContext` spawn helper.

Regression coverage: `testParseMessageSetRecursionDepthCarriedFromParent` in
`GPBWireFormatTests` builds MessageSet-of-MessageSet payloads (each layer is
`MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...`, adding
2 to the recursion depth per layer). It verifies that 50 layers (depth 100,
`kDefaultRecursionLimit`) parses successfully, while 51 layers (depth 101,
`kDefaultRecursionLimit + 1`) fails with
`GPBCodedInputStreamErrorRecursionDepthExceeded`. Existing
`testErrorRecursionDepthReached` and the rest of `GPBWireFormatTests` continue
to pass unchanged.

## Test plan

- Existing ObjC test suite passes (`GPBWireFormatTests`,
  `GPBMessageTests+Serialization`, `GPBCodedInputStreamTests`,
  `GPBUnknownFieldsTest`).
- New `testParseMessageSetRecursionDepthCarriedFromParent` passes for both 50
  layers (passing at kDefaultRecursionLimit) and 51 layers (failing at
  kDefaultRecursionLimit + 1).

Closes #27571

PiperOrigin-RevId: 957331709
@github-actions github-actions Bot added untriaged auto added to all issues by default when created. and removed wait for user action labels Aug 4, 2026
copybara-service Bot pushed a commit that referenced this pull request Aug 4, 2026
## Summary

`GPBMessage::parseMessageSet:` reads each MessageSet item's payload bytes into
a separate buffer (because the type-id and payload tags can appear in any
order), then allocates a fresh `GPBCodedInputStream` to decode that buffer. The
fresh stream's `state_.recursionDepth` starts at `0` even when the parent
stream is already several MessageSet items deep, so a chain of nested
MessageSet items grows the native call stack without ever tripping the
documented `kDefaultRecursionLimit` (100). Other parser entry points that
recurse on the same stream (`readGroup:`, `readMessage:`, `readMapEntry:`,
`SkipToEndGroupInternal`) already increment and check `recursionDepth`; the
MessageSet path was the only spot where depth tracking did not cross the stream
boundary.

This change adds a package-private `-initWithData:parentRecursionDepth:`
initializer to `GPBCodedInputStream` that seeds the child stream's depth from
the parent's and runs `CheckRecursionLimit` before parsing begins. To prevent
memory leaks if `CheckRecursionLimit` raises an exception during
initialization, the check is wrapped in a `@try/@catch` block that releases
`self`. `parseMessageSet:` uses that initializer, so depth tracking carries
across the fresh stream. This mirrors the depth-inheritance done by the C++
`ParseContext` spawn helper.

Regression coverage: `testParseMessageSetRecursionDepthCarriedFromParent` in
`GPBWireFormatTests` builds MessageSet-of-MessageSet payloads (each layer is
`MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...`, adding
2 to the recursion depth per layer). It verifies that 50 layers (depth 100,
`kDefaultRecursionLimit`) parses successfully, while 51 layers (depth 101,
`kDefaultRecursionLimit + 1`) fails with
`GPBCodedInputStreamErrorRecursionDepthExceeded`. Existing
`testErrorRecursionDepthReached` and the rest of `GPBWireFormatTests` continue
to pass unchanged.

## Test plan

- Existing ObjC test suite passes (`GPBWireFormatTests`,
  `GPBMessageTests+Serialization`, `GPBCodedInputStreamTests`,
  `GPBUnknownFieldsTest`).
- New `testParseMessageSetRecursionDepthCarriedFromParent` passes for both 50
  layers (passing at kDefaultRecursionLimit) and 51 layers (failing at
  kDefaultRecursionLimit + 1).

Closes #27571

PiperOrigin-RevId: 957331709
copybara-service Bot pushed a commit that referenced this pull request Aug 4, 2026
## Summary

`GPBMessage::parseMessageSet:` reads each MessageSet item's payload bytes into
a separate buffer (because the type-id and payload tags can appear in any
order), then allocates a fresh `GPBCodedInputStream` to decode that buffer. The
fresh stream's `state_.recursionDepth` starts at `0` even when the parent
stream is already several MessageSet items deep, so a chain of nested
MessageSet items grows the native call stack without ever tripping the
documented `kDefaultRecursionLimit` (100). Other parser entry points that
recurse on the same stream (`readGroup:`, `readMessage:`, `readMapEntry:`,
`SkipToEndGroupInternal`) already increment and check `recursionDepth`; the
MessageSet path was the only spot where depth tracking did not cross the stream
boundary.

This change adds a package-private `-initWithData:parentRecursionDepth:`
initializer to `GPBCodedInputStream` that seeds the child stream's depth from
the parent's and runs `CheckRecursionLimit` before parsing begins. To prevent
memory leaks if `CheckRecursionLimit` raises an exception during
initialization, the check is wrapped in a `@try/@catch` block that releases
`self`. `parseMessageSet:` uses that initializer, so depth tracking carries
across the fresh stream. This mirrors the depth-inheritance done by the C++
`ParseContext` spawn helper.

Regression coverage: `testParseMessageSetRecursionDepthCarriedFromParent` in
`GPBWireFormatTests` builds MessageSet-of-MessageSet payloads (each layer is
`MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...`, adding
2 to the recursion depth per layer). It verifies that 50 layers (depth 100,
`kDefaultRecursionLimit`) parses successfully, while 51 layers (depth 101,
`kDefaultRecursionLimit + 1`) fails with
`GPBCodedInputStreamErrorRecursionDepthExceeded`. Existing
`testErrorRecursionDepthReached` and the rest of `GPBWireFormatTests` continue
to pass unchanged.

## Test plan

- Existing ObjC test suite passes (`GPBWireFormatTests`,
  `GPBMessageTests+Serialization`, `GPBCodedInputStreamTests`,
  `GPBUnknownFieldsTest`).
- New `testParseMessageSetRecursionDepthCarriedFromParent` passes for both 50
  layers (passing at kDefaultRecursionLimit) and 51 layers (failing at
  kDefaultRecursionLimit + 1).

Closes #27571

PiperOrigin-RevId: 957331709
@protocolbuffers protocolbuffers deleted a comment from aboashor Aug 4, 2026
@copybara-service copybara-service Bot closed this in dd9d2ad Aug 4, 2026
@tonyliaoss

Copy link
Copy Markdown
Member

To move this along, we fixed this at dd9d2ad.

tonyliaoss added a commit that referenced this pull request Aug 4, 2026
…pick

[ObjC] Carry recursion depth across MessageSet item parsing (#27571)
wheakerd pushed a commit to wheakerd/protobuf that referenced this pull request Aug 5, 2026
…buffers#27571)

## Summary

`GPBMessage::parseMessageSet:` reads each MessageSet item's payload bytes into
a separate buffer (because the type-id and payload tags can appear in any
order), then allocates a fresh `GPBCodedInputStream` to decode that buffer. The
fresh stream's `state_.recursionDepth` starts at `0` even when the parent
stream is already several MessageSet items deep, so a chain of nested
MessageSet items grows the native call stack without ever tripping the
documented `kDefaultRecursionLimit` (100). Other parser entry points that
recurse on the same stream (`readGroup:`, `readMessage:`, `readMapEntry:`,
`SkipToEndGroupInternal`) already increment and check `recursionDepth`; the
MessageSet path was the only spot where depth tracking did not cross the stream
boundary.

This change adds a package-private `-initWithData:parentRecursionDepth:`
initializer to `GPBCodedInputStream` that seeds the child stream's depth from
the parent's and runs `CheckRecursionLimit` before parsing begins. To prevent
memory leaks if `CheckRecursionLimit` raises an exception during
initialization, the check is wrapped in a `@try/@catch` block that releases
`self`. `parseMessageSet:` uses that initializer, so depth tracking carries
across the fresh stream. This mirrors the depth-inheritance done by the C++
`ParseContext` spawn helper.

Regression coverage: `testParseMessageSetRecursionDepthCarriedFromParent` in
`GPBWireFormatTests` builds MessageSet-of-MessageSet payloads (each layer is
`MSetMessage -> MSetMessageExtension1.recursive -> MSetMessage -> ...`, adding
2 to the recursion depth per layer). It verifies that 50 layers (depth 100,
`kDefaultRecursionLimit`) parses successfully, while 51 layers (depth 101,
`kDefaultRecursionLimit + 1`) fails with
`GPBCodedInputStreamErrorRecursionDepthExceeded`. Existing
`testErrorRecursionDepthReached` and the rest of `GPBWireFormatTests` continue
to pass unchanged.

## Test plan

- Existing ObjC test suite passes (`GPBWireFormatTests`,
  `GPBMessageTests+Serialization`, `GPBCodedInputStreamTests`,
  `GPBUnknownFieldsTest`).
- New `testParseMessageSetRecursionDepthCarriedFromParent` passes for both 50
  layers (passing at kDefaultRecursionLimit) and 51 layers (failing at
  kDefaultRecursionLimit + 1).

Closes protocolbuffers#27571

PiperOrigin-RevId: 959041690
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

objective-c untriaged auto added to all issues by default when created.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants