Skip to content

Bound the first client frame before allocating for it - #137

Merged
Zaldaryon merged 4 commits into
indevfrom
fix/first-frame-bound
Sep 27, 2026
Merged

Zaldaryon merged 4 commits into
indevfrom
fix/first-frame-bound

Conversation

@Pixnop

@Pixnop Pixnop commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

TryReadFirstFrameAsync reads a client's opening frame before that client has identified: four header bytes, a declared length, and then an allocation of that many bytes ahead of receiving any of them, followed by a wait of up to QueryTimeoutMs (1500ms by default) for the rest to arrive. The only guard on the declared length refused anything above 256 MiB, so anything under that let an unauthenticated peer choose the size of an allocation and hold the proxy waiting on it at the cost of a four-byte write, with no upper bound worth calling small.

A real first frame is nowhere near that size. The status query and LoginTokenQuery this proxy handles are a handful of bytes, and Identification, read straight out of the 1.22.6 assembly's Packet_ClientIdentification, is nine fields: MdProtocolVersion, Playername, MpToken, ServerPassword, PlayerUID, NetworkVersion, ShortGameVersion, ViewDistance and RenderMetaBlocks, all short strings or ints and no mod list (that travels server to client, not the other way). This change adds MaxFirstFrameSize, 64 KiB, checked in ClientSessionRunner.TryReadFirstFrameAsync before the allocation runs, well above anything a real client sends and far below the old 256 MiB ceiling. A declared length past the bound throws the same InvalidDataException the old oversized case already threw, which the caller already handles by logging and closing the socket without dialing a backend or writing a reply, so the rejected session ends through the path that was already there rather than a new one. VsWire.MaxFrameSize and the FrameSniffer are untouched, since mid-session frames after identification are a separate, legitimately larger case.

Three tests were added in FirstFrameBoundTests.cs, built on the existing SessionHarness and RecordingBackend used by the other socket-level session tests. One drives a client that declares 200 MiB and sends nothing else, and asserts the session closes and no backend connection is ever opened; against the unfixed code this same scenario allocates the 200 MiB, waits out the full query timeout, and then still proceeds to dial the backend, which the test catches. Another sends exactly MaxFirstFrameSize bytes and confirms that frame is still accepted and forwarded as before. The third sends one byte over the bound and confirms it is refused. The existing first-frame tests (Identification first, status query first, a quiet client, sticky reconnect routing) were run unchanged and still pass.

An unidentified client picks the size of the buffer TryReadFirstFrameAsync
allocates for the first frame by writing four header bytes, before any of
the declared payload has arrived; the only existing check refused declared
lengths above 256 MiB, which still let a peer force a large allocation and
a wait out to QueryTimeoutMs for nothing. Real first frames (the status
query, LoginTokenQuery, Identification) are a few hundred bytes at most, so
bound the length to 64 KiB before allocating and reuse the existing
InvalidDataException path, which already ends the session without dialing
a backend or writing a reply.
@Pixnop
Pixnop requested a review from Zaldaryon September 20, 2026 09:44
Client.RemoteEndPoint cannot be null once the header read above has already
succeeded on the same stream, so the null-coalescing fallback was dead
weight that only left an unreachable branch for coverage to complain about.
…dress

The bound comment claimed Identification carries a mod list. It does not: the
1.22.6 Packet_ClientIdentification has nine fields, short strings and two
ints, and mod lists travel server to client, never the other way. The comment
now names what the packet actually carries instead.

The over-bound log line also read client.Client.RemoteEndPoint directly,
which a concurrent teardown can dispose out from under it. ClientSessionRunner
already has the session in scope by the time it reads the first frame, and
ProxySession captures the client address once at construction for exactly
this reason, so the log now reads session.ClientRemote instead.
@Zaldaryon

Copy link
Copy Markdown

The PR currently targets indev at aaa5a58, while current indev is d4163ff and contains three commits not in this branch. Could you update this PR against current indev before I review it? The current Build & ServerMod tests and Sonar checks pass. I will recheck the updated range once it is pushed.

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

Please update this branch against current indev before the code review proceeds. The PR targets aaa5a58, while indev is at d4163ff, so the histories are three commits apart in both directions. The current Build & ServerMod tests and Sonar checks pass, but review and test evidence should cover the current integration branch. After updating the PR, please request review again. I found no line-specific code defect on this stale head; this request is for branch freshness.

@Pixnop
Pixnop requested a review from Zaldaryon September 26, 2026 11:32
@Pixnop

Pixnop commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

Branch updated with a merge of current indev (head cc5627c), no code change; unit suites green (716/717, 312/312, 115/115, 97/97, only known local-only ProxyBootTests.ARegistryThatCannotBind_ExitsTwoRatherThanCrashingOnTheWayOut failing) and the 30 ServerMod scenarios green; review re-requested from Zaldaryon.

@sonarqubecloud

Copy link
Copy Markdown

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

Approving on head cc5627c. The branch is synced with current indev, all CI checks pass, and local verification confirms the fix:

  • FirstFrameBoundTests passes 3/3, and restoring the old 256 MiB ceiling fails ClientThatOnlyDeclaresAHugeLength_IsDisconnectedWithoutDialingABackend as expected.
  • Engine comparison confirms Packet_ClientIdentificationSerializer writes only the nine declared fields and no mod list, so 64 KiB is a safe ceiling.
  • The 7-day review window for behavior changes is satisfied.

Two non-blocking follow-up notes:

  1. Nimbus.Proxy/Core/ClientSessionRunner.cs:221: the trace line prefixes with [first-frame] instead of [s{session.Id}] used across the rest of the class. Folding the session id into the prefix helps log filtering.
  2. Nimbus.Proxy/Core/ClientSessionRunner.cs:97: throwing into the general session catch-all produces a Warn log for "session crashed" on an unauthenticated 4-byte write. Handling InvalidDataException explicitly or logging at Trace would prevent warning noise from port probes.

@Zaldaryon
Zaldaryon merged commit 648e3c0 into indev Sep 27, 2026
4 checks passed
@Zaldaryon
Zaldaryon deleted the fix/first-frame-bound branch September 27, 2026 18:16
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