Skip to content

fix(codegen-go): bound decoded sequence lengths - #398

Open
YuanYuYuan wants to merge 8 commits into
mainfrom
fix/go-sequence-length-bound
Open

YuanYuYuan wants to merge 8 commits into
mainfrom
fix/go-sequence-length-bound

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The generated Go decoder allocates a slice from a length that it reads off the wire, with no limit. A hostile or corrupt message with a huge length makes the decoder allocate until the process runs out of memory. This change bounds every such reservation by the bytes left in the input.

Key Changes

Where Before After
Sequence of primitives, strings, times and durations reads the length, calls make with it rejects a negative length or a length above the bytes left, then calls make
Sequence of nested messages reserves the whole count, with no bound reserves min(count, bytes left / minimum element size) elements, and rejects an element before it allocates it when fewer bytes are left than its known minimum size
A sequence of empty messages decoded still decoded (the bound does not reject it)
Generator no size of a nested message minCDRSize computes a lower bound on the encoded size of one message

A bound on the element count is not a bound on memory. The bound for primitives, strings, times and durations counts one byte per element. A wide element type can therefore still reserve several times the input size, 8 times for float64. Nested messages use the minimum element size below. An element that holds a uint8[65536] field takes at least 65536 bytes. A count of 65536 with 65536 bytes left passes a count bound and still reserves 4 GiB.

The emitted bound for a sequence of nested messages is:

capHint := arrLen
if bound := (len(data) - offset) / 65536; capHint > bound { capHint = bound }

The divisor is the minimum encoded size of one element, at least 1. The generator computes it from the manifest:

Field Minimum bytes
primitive its size
fixed array element minimum, n times
string 4 (the decoder accepts a zero length)
time or duration 8
sequence 4 (the length prefix)
nested message the minimum of its own fields, with a guard against a cycle

Before each var elem, the loop checks that at least that many bytes are left. A Go array field is part of its element. An element with a uint8[1073741824] field is therefore a 1 GiB value, even when no bytes remain to decode it. The slice bound does not prevent that allocation. The check applies to an element with a known minimum size of 1 byte or more.

The generator ignores alignment padding, which only adds bytes. A type that is not in the manifest counts as 0.

What stays unbounded: an element of zero bytes, which is a message with no fields, or a type that is not in the manifest. A sequence of such elements loops count times, which costs CPU time. A message with no fields has a Go size of zero, so its reservation costs no memory. A type that is not in the manifest gets no byte-aware bound: the code reserves min(count, bytes left) elements of the real element size, which is large when that type holds a big array. The generator does not refuse such a type.

What fails without this

test before after
TestCustomSequenceReservationIsByteAware fails: the emitted code has no size bound passes
TestHostileBigElements (inside TestRoundtripRejectsHostileSequenceLength) fails: the decoder reserved about 4 GiB passes: under 1 MiB
TestHostileHugeElement (inside TestRoundtripRejectsHostileSequenceLength) fails without the per-element check: the decoder reserved 1073774648 bytes passes: under 1 MiB
TestMinCDRSize added with the fix passes

Red for the per-element check: with the check disabled in the generator, TestHostileHugeElement fails on f3de75a50. Red: go test ./... -count=1 -v in crates/hiroz-codegen-go fails at the test-only commit 6bb72c9. Green: the fix commit is 68cce85. The last commit bafe726 keeps the tests 32-bit clean. Two earlier commits pin and fix the length bound for primitives, strings, times and durations: the test b6a2157, then the fix 06fe8d5.

Cross-arch probes

Two probes build the decoder with GOARCH=386, where int(uint32) of 0xFFFFFFFF is negative. The test now classifies the host first.

before after
go tool dist list has no GOOS/386 pair (for example darwin/386) the probe fails: the build is refused skipped, with the reason named
the pair builds but the host cannot run the binary (exec format error) skipped skipped, unchanged
the pair builds and runs (for example linux/386) runs and asserts runs and asserts, unchanged

On the head commit f3de75a50, on Linux, go test ./... -count=1 -v in crates/hiroz-codegen-go passes: 18 top-level tests and 28 subtests, 0 failed. Both 386 probes ran and passed. go vet ./... and gofmt -l print nothing. GOARCH=386 CGO_ENABLED=0 go vet ./... passes too, so the test package compiles for 32-bit targets. The skip path ran only in the unit test of the classifier, not on macOS. The pair list on Linux has linux/386 and has no darwin/386.

Breaking Changes

None. A well-formed message decodes as before.

@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
see 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI 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.

🟡 Changes recommended

Nested-message decoding still permits excessive allocations before rejecting hostile input.

1 open issue
What changed in this PR

This PR hardens generated Go CDR decoders against excessive allocations caused by hostile sequence lengths.

Changes:

  • Adds pre-allocation length checks for primitive, string, time, and duration sequences.
  • Uses manifest-derived minimum encoded sizes to limit nested-message reservations.
  • Adds hostile-input, valid-boundary, and 32-bit regression probes.
File Description
crates/​hiroz-codegen-go/​roundtrip_integration_test.go Adds runtime allocation probes and cross-architecture checks.
crates/​hiroz-codegen-go/​main.go Registers manifest messages before generation.
crates/​hiroz-codegen-go/​main_test.go Tests generated bounds and minimum-size calculations.
crates/​hiroz-codegen-go/​generator.go Emits sequence checks and size-aware nested reservations.

Review effort: Balanced

Comment thread crates/hiroz-codegen-go/generator.go Resolved
A sequence element of a type with a huge fixed array is a huge Go array, so var elem reserved it even when no bytes were left to decode. Reject the element first when fewer bytes are left than its known minimum size.

This branch has not been deployed

No deployments
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