Skip to content

[fix] Bound peer-controlled sizes in the connection reader and recover from panics on connection goroutines - #1533

Open
zknill wants to merge 4 commits into
apache:masterfrom
ably-forks:fix-peer-controlled-frame-sizes
Open

zknill wants to merge 4 commits into
apache:masterfrom
ably-forks:fix-peer-controlled-frame-sizes

Conversation

@zknill

@zknill zknill commented Aug 27, 2026 •

Copy link
Copy Markdown

Master Issue: relates to #435 / #709 (the buffer.Read bounds check this completes)

Motivation

The connection reader trusts two sizes a peer sends on the wire, and a panic on any connection goroutine terminates the whole process.

The frame size check was guarded on maxMessageSize != 0, and that field is only set from the broker's Connected response. The check therefore never ran on the first read of a connection, before the peer had authenticated, and the frame size decides how large a buffer readAtLeast allocates. The check also compared as int32, so any size of 2^31 or more turned negative and passed.

The command size is a second length read off the wire, and nothing checked it fits inside the frame. A frame size of 8 with a command size of 4294967295 defeats the bounds check in buffer.Read (the readerIdx+size addition wraps uint32) and panics with slice bounds out of range [8:7], taking down the process.

Modifications

  • Bound the frame size by MaxFrameSize until the handshake supplies maxMessageSize, and compare in uint32 so large sizes cannot pass as negative.
  • Reject a command size that does not fit within its frame, written as cmdSize > frameSize-4 so neither side of the comparison can wrap.
  • Rewrite the buffer.Read bounds check to compute the readable remainder by subtraction, and refuse reads after Skip has moved readerIdx past the end of the data.
  • Defer a recoverPanic guard on each goroutine the package starts (connect/run, reader, ping check). A recovered panic is logged with its stack and closes the connection.

zknill added 4 commits August 27, 2026 17:59
Two defects meant the frame size a peer sends was effectively unbounded.

The limit was guarded on connection.maxMessageSize being non-zero, but
that field is taken from the broker's Connected response, so it is still
zero on the first read of a connection. The check therefore did not run at
all until the handshake had completed. That first read happens before the
peer has authenticated, and the frame size it carries decides how large a
buffer readAtLeast allocates for the rest of the frame, so a peer could
choose that allocation size.

The comparison also converted the size to int32. Any size of 2^31 or more
has its top bit set, so the conversion made it negative and the check
accepted it, both before and after the handshake. Such a size is not
harmless once accepted: readAtLeast computes totalFrameSize * 2 to size
the buffer, which overflows uint32 and wraps to a small value, leaving the
read to fail on a short buffer well after the point the size should have
been refused.

Fall back to MaxFrameSize while maxMessageSize is unknown, so the frame is
always bounded by the protocol maximum, and keep the comparison in uint32
so no size can pass by being reinterpreted as negative. Widening
maxFrameSize to uint32 also removes an int32 overflow in
maxMessageSize + MessageFramePadding.

Both halves are needed: bounding sizes below 2^31 is what stops the large
allocation, and comparing as unsigned is what stops the largest sizes
skipping the check entirely.

Add a reader test that drives readSingleCommand over a net.Pipe, covering
both handshake states and sizes at and above 2^31. The pipe carries a read
deadline so a regression that accepts an oversized frame fails on the
assertion rather than blocking in io.ReadAtLeast waiting for bytes that
never arrive.
The command size is a second length read straight off the wire, and it
indexes into the frame that has just been read. Nothing checked that it
fits inside that frame before it was passed to buffer.Read.

A peer could therefore declare an unremarkable frame size and then a
command size far larger than the frame holds. With a frame size of 8 and
a command size of 4294967295, buffer.Read panics with a slice bounds
error, taking down the process rather than the connection:

    panic: runtime error: slice bounds out of range [8:7]
      internal.(*buffer).Read                         buffer.go
      internal.(*connectionReader).readSingleCommand  connection_reader.go
      internal.(*connection).doHandshake              connection.go
      internal.(*connection).start.func1              connection.go

Reject a command size that does not fit within the frame, and a frame too
short to carry one at all. The comparison is written as frameSize-4 so
that neither side of it can overflow.

Add reader cases for a command size at the top of the uint32 range, one
just past the end of the frame, and a frame shorter than the command size
field itself.
The bounds check added in apache#709 for issue apache#435 adds readerIdx to the
requested size and compares the sum against the length of the data. That
addition is uint32 arithmetic and wraps, so a large size produces a small
sum which passes the check and then panics on the slice expression below.

With readerIdx at 8 and a size of 4294967295 the sum wraps to 7, 7 is not
past the end of a 4096 byte buffer, and the slice becomes b.data[8:7]:

    panic: runtime error: slice bounds out of range [8:7]

The check is therefore defeated by the values it exists to reject, which
completes the fix apache#709 intended. Sizes reaching Read come straight off the
wire, so this is reachable from a peer rather than theoretical.

Compute the readable remainder by subtraction instead, so nothing can
wrap, and report that remainder in the log line rather than the wrapped
sum. Skip does not bound readerIdx, so also handle readerIdx having
already moved past the end of the data.

Add cases for a size past the end, a size one past the readable
remainder, sizes that make the addition wrap, and a read attempted after
Skip has moved readerIdx beyond the data.
The package starts goroutines for connecting, reading and the keep-alive
check, and none of them guard against a panic. A panic on any of them
reaches the top of its goroutine and terminates the calling process, so a
fault while talking to one broker costs every other connection in that
process, and any work unrelated to Pulsar that happens to share it.

Add recoverPanic and defer it at the top of each goroutine this package
starts. A recovered panic is logged with its stack and closes the
connection, which the client already knows how to reconnect.

Each goroutine needs its own guard. recover only reaches the goroutine it
runs in, so the guard in start cannot cover the reader and ping-check
goroutines that run starts. Close is idempotent, so the guard is safe on a
connection the panicking path had already closed.

This changes what a fault costs, not whether one happens. A deterministic
panic becomes a reconnect loop rather than a process exit, which is why it
does not replace bounding the sizes a peer can ask for.
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.

1 participant