Skip to content

fix(#39): enforce tuple nesting depth explicitly with MAX_NESTING_DEPTH - #67

Merged
s2x merged 2 commits into
masterfrom
fix/issue-39-tuple-nesting-depth
Jul 4, 2026
Merged

fix(#39): enforce tuple nesting depth explicitly with MAX_NESTING_DEPTH#67
s2x merged 2 commits into
masterfrom
fix/issue-39-tuple-nesting-depth

Conversation

@s2x

@s2x s2x commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #39 — the tuple decoder/encoder was recursing without limit on
nested tuples, so a stored value consisting of hundreds of kilobytes
of \x05 bytes (TYPE_NESTED) consumed the call stack until the
process aborted whenever an application unpacked bytes produced by a
less-trusted writer.

This change introduces a public Tuple::MAX_NESTING_DEPTH = 100 bound,
enforced symmetrically on both the encode and decode paths. Any
payload whose deepest recursion would exceed the limit now raises
\InvalidArgumentException immediately at the offending call, with a
message identifying both the constant and the offending depth, instead
of silently consuming the stack.

The wire format is unchanged: payloads that round-trip just at the
limit continue to be accepted, so legitimate deeply-nested user data
is unaffected.

Changes

  • src/Tuple/Tuple.php — new public MAX_NESTING_DEPTH constant
    • assertDepth() helper, threaded through
      encode/decodeNestedTuple, encode/decodeElement,
      elementHasIncompleteVersionstamp, countVersionstamps and
      findVersionstampOffset.
  • tests/Unit/Tuple/TupleNestingDepthTest.php — 13 unit tests:
    constant exposure; unpack/pack/packWithVersionstamp/hasIncomplete
    at the accepted and rejected boundaries; the 300 KB PoC buffer
    (must reject in well under 1 s); pack→unpack round-trip at the
    limit; encode-side helpers rejecting deep arrays even when no
    Versionstamp is present; and a direct decodeNestedTuple() call
    via reflection so a future maintainer cannot accidentally bypass
    the guard.
  • tests/Integration/TupleNestingDepthTest.php — 2 integration
    tests against the live 5-node cluster: round-trip at the exact
    boundary and the PoC payload refused on read.
  • docs/tuple-layer.md — new "Nesting Depth Limit" section
    documenting MAX_NESTING_DEPTH and the DoS guard.
  • CHANGELOG.md[Unreleased] / Fixed entry for [Security] Unbounded recursion in Tuple decoder enables denial of service #39.

Verification

  • composer lint clean (PHPCS, Rector, PHPStan).
  • composer test:unit clean (380 tests pass).
  • Integration tests run by the CI E2E Tests (5-node FDB cluster)
    job against the 5-node FDB cluster.

Fixes #39

Piotr Hałas added 2 commits July 4, 2026 15:23
Previously, the tuple decoder and encoder recursed without limit on
nested tuples (TYPE_NESTED bytes on the wire, PHP arrays in the API).
A stored value consisting of hundreds of kilobytes of `\x05` bytes
(within FoundationDB's 10 MB value limit) would consume the call
stack until the process aborted — a denial-of-service vector when an
application called `Tuple::unpack()` on bytes produced by a
less-trusted writer.

The fix introduces a public `Tuple::MAX_NESTING_DEPTH = 100` bound,
applied symmetrically on the encode and decode paths of
`encode/decodeElement` and `encode/decodeNestedTuple`, plus the
encode-side helpers `findVersionstampOffset`, `countVersionstamps`
and `elementHasIncompleteVersionstamp`. Any input whose deepest
recursion step would exceed the limit raises `\InvalidArgumentException`
immediately at the offending call, with a message identifying both the
limit and the offending depth, instead of silently consuming the stack.

The guard is inclusive: a payload whose deepest recursion reaches
exactly `MAX_NESTING_DEPTH` is still accepted, so legitimate
deeply-nested user data round-trips unchanged.

Changes:
- src/Tuple/Tuple.php: introduce `MAX_NESTING_DEPTH = 100`, thread
  an explicit `$depth` counter through the recursive helpers, and
  call `assertDepth()` at the top of `encodeNestedTuple` /
  `decodeNestedTuple` / `elementHasIncompleteVersionstamp` /
  `countVersionstamps` / `findVersionstampOffset` so the limit is
  enforced before the next recursion step consumes the stack.
- tests/Unit/Tuple/TupleNestingDepthTest.php: 13 unit tests
  covering the constant, unpack / pack / packWithVersionstamp /
  hasIncompleteVersionstamp at the accepted boundary, the
  rejected boundary, the 300 KB PoC buffer (must reject in
  well under 1 s), the pack→unpack round-trip at the limit,
  the encode-side helpers rejecting deep arrays even when no
  Versionstamp is present, and a direct `decodeNestedTuple()` call
  through reflection to guarantee future maintainers cannot
  accidentally bypass the guard.
- tests/Integration/TupleNestingDepthTest.php: 2 integration tests
  against the live 5-node FoundationDB cluster — round-trip at the
  exact boundary, and the 300 KB PoC payload read back through the
  cluster refused on read.
- docs/tuple-layer.md: document `MAX_NESTING_DEPTH` and the
  deep-nest DoS guard.
- CHANGELOG.md: entry under `[Unreleased] / Fixed` for #39.

Fixes #39

Co-authored-by: Piotr Hałas <piotr.h@decodo.com>
…limit

The 5-node FDB cluster enforces a 100,000-byte value limit, which
the previous integration test breached with a 300,000-byte payload.
Reduce to 500 \x05 bytes — still well above MAX_NESTING_DEPTH=100 so
the guard fires, while staying within the FDB-side size limit.
@s2x
s2x merged commit 51fbba4 into master Jul 4, 2026
6 checks passed
@s2x
s2x deleted the fix/issue-39-tuple-nesting-depth branch July 4, 2026 13:33
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.

[Security] Unbounded recursion in Tuple decoder enables denial of service

1 participant