Robustness batch from the 2026-09-13 audit (closes #190) - #191
Conversation
Four findings from the v1.4.6 security review, none network-reachable. - override_send_and_receive with either override callback unset is refused at client and server create time, with a new create error code each. The receive override was called with no null check in both update paths. - a loopback client or loopback server slot without send_loopback_packet_callback is refused at the point loopback is entered, in every build, rather than calling a null pointer on the first send. - the network simulator checks its allocation and drops the packet instead of memcpy-ing into null. - NETCODE_MAX_ADDRESS_STRING_LENGTH moves to netcode.h so a header-only caller can size the netcode_address_to_string buffer, and the contract is documented on the declaration. - sodium/NOTES.md and IMPLEMENTERS.md record that a console port must register a real RNG: libsodium's __ORBIS__/__PROSPERO__ randombytes_sysrandom_buf never writes the buffer. No wire format change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames
left a comment
There was a problem hiding this comment.
Reviewed at b5091c5c0290be2f019929932149b291824b8af4 against base 20d1f504ef0cff4f5123ec015a835acfa20c051a.
Verdict: HOLD (1 defect found: memory leak in new test).
Finding: test_network_simulator_allocation_failure leaks 256 bytes in receive_packet_data[0]
- Location:
netcode.c:7565 - Trigger: In
test_network_simulator_allocation_failure:check( netcode_network_simulator_receive_packets( network_simulator, &to, 16, receive_packet_data, receive_packet_bytes, receive_from ) == 1 ); netcode_network_simulator_destroy( network_simulator );
- Explanation:
netcode_network_simulator_receive_packetstransfers ownership of received packet buffers to the caller (settingpending_receive_packets[i].packet_data = NULLin the simulator, as seen intest_network_simulator_determinism:7635andclient_receive_packets:3315). Whennetcode_network_simulator_destroyis called, that packet is no longer inside the simulator, and the test never freedreceive_packet_data[0]. - Witness: Hosted CI job sanitizers (asan+ubsan) #103728448453 fails under LeakSanitizer:
==2253==ERROR: LeakSanitizer: detected memory leaks Direct leak of 256 byte(s) in 1 object(s) allocated from: #0 malloc #1 test_toggle_allocate_function netcode.c:7317 #2 netcode_network_simulator_queue_packet netcode.c:2623 #3 netcode_network_simulator_send_packet netcode.c:2670 #4 test_network_simulator_allocation_failure netcode.c:7561 - Repair: Add
free( receive_packet_data[0] );immediately after line 7565.
Other Robustness Items (Verified Clean)
receive_packet_override(client :2921, server :4200): Create-time refusal withNETCODE_CLIENT_CREATE_ERROR_MISSING_OVERRIDE_CALLBACK(7) andNETCODE_SERVER_CREATE_ERROR_MISSING_OVERRIDE_CALLBACK(8) innetcode.h. Network-input config checked at create time in all builds; clean tests.send_loopback_packet_callback(client :3654, server :5396): Release check + error log at loopback entry points, preventing NULL function pointer calls.- Simulator allocation failure (:2623): Allocates to local before modifying slot; drops packet gracefully on NULL allocator return.
NETCODE_MAX_ADDRESS_STRING_LENGTH(netcode.h:139): Exported with full contract comment on buffer size and null termination.- Console RNG documentation (
sodium/NOTES.md,IMPLEMENTERS.md): Clearly states stub RNG behavior on__ORBIS__/__PROSPERO__and requires registering a platform cryptographic RNG beforenetcode_init.
Once free( receive_packet_data[0] ); is added to test_network_simulator_allocation_failure, this will be a clean APPROVE.
rowan-claude
left a comment
There was a problem hiding this comment.
APPROVE
Cold read of head b5091c5 against origin/main (20d1f50), STYLE-C.md in mas-bandwidth/standard, and the ports that mirror this header. Every red below was reproduced by reverting that hunk alone on the branch and rebuilding netcode_test. No HOLD-grade finding. Three NOTEs, one of which needs a line in the PR body before merge.
1a. receive_packet_override unchecked — real, fix matches doctrine
Defect on main: netcode.c:3263 and :5039 call config.receive_packet_override under if ( override_send_and_receive ) with no check; the send twin is guarded at :2768 (else if ( send_packet_override )). Real in debug and release.
Rule: STYLE-C §5, "Config validation runs in every build: reliable_config_valid range-checks each field ... logs which one failed, and create returns NULL (reliable.c:554-618)." The new checks at netcode.c:2922 and :4200 are that shape exactly (if + error: log + NULL, error code recorded), and reliable_config_valid carries no assert beside its ifs, so no assert is owed here.
Red, hunk reverted: check failed: ( netcode_client_create( "0.0.0.0:50000", &override_config, 0.0 ) == NULL ), test_client_create_error, netcode.c:7410, exit 1.
Behaviour for a correct caller: unchanged. The one caller this refuses that main accepted is override_send_and_receive = 1 with send_packet_override = NULL: on main :3338 passes NULL as the override, :2768 falls through to netcode_socket_send_packet on a socket that was never created (:2876 skips creation), whose only guard is netcode_assert( socket->handle != 0 ) (:868) — so in release that caller's sends went to fd 0. Not a correct caller.
1b. Loopback callback — every path covered; NOTE on the missing assert
Paths: client->loopback = 1 is written at one line on main (:3635, inside netcode_client_connect_loopback); server->client_loopback[i] = 1 at one line (:5369, inside netcode_server_connect_loopback_client). Both call sites (:3534, :5255) branch on those flags, and nothing else sets them, so the two entry guards close both. netcode_client_send_packet also returns on state != CONNECTED, which the refused connect never reaches.
Red, hunk reverted: check failed: ( netcode_client_loopback( client ) == 0 ), test_loopback_callback_required, netcode.c:10464, exit 1.
NOTE (style, not a defect). Rule: STYLE-C §5, "What is checked in release. Anything from the network, and any public argument that could be out of range, gets an if" and then "Both, on purpose. The assert catches it in your debugger; the if catches it in the shipped build." The PR body drops the assert "on purpose" because the test drives the condition in debug. The suite already has the pattern for exactly that: STYLE-C §5 says netcode's handler "lets a custom handler continue ... and its tests use that to run the release guards inside a debug build", and this tree does it at netcode.c:7174-7189 (test_runtime_guards_assert_handler) and :6280-6328 (test_oor_assert_handler). Adding netcode_assert( client->config.send_loopback_packet_callback ) beside each new if and installing that handler in test_loopback_callback_required keeps "both, on purpose". Server side keeps its existing call-site assert at :5292; client call site still has none. Not blocking: the if is the release guard the rule requires.
2. Simulator allocation — real, red is the SIGSEGV, ring entry question
Defect on main: netcode.c:2629-2631, allocate_function result assigned then memcpy'd with no check.
Red, hunk reverted: ./build/bin/test exit 139; under a tty (script -q /dev/null) exit 11 with the last test name printed test_network_simulator_allocation_failure. So the red is the segfault in this test, not a later one.
On the "entry left intact" question: the fix returns before the free at :2631, before the slot writes, and before current_index++ at :2643. So the index is not advanced on failure and the premise of an advanced index with a dead slot does not arise. What is observable is only on the failure path: the entry that would have been evicted from that slot stays queued and still delivers if its delivery_time has not passed (ring of NETCODE_MAX_CLIENTS * 256 entries, :2502). For a caller whose allocator never fails, byte-identical behaviour. Fix is the minimal one.
3. NETCODE_MAX_ADDRESS_STRING_LENGTH — real, minimum correct, mirrors unaffected
Red, header-only TU against main's netcode.h: error: use of undeclared identifier 'NETCODE_MAX_ADDRESS_STRING_LENGTH'; green against the branch header, rc=0.
Widest write (netcode.c:324-380): IPv6 with port is [ + 45 (INET6_ADDRSTRLEN-1) + ]: + 5 = 53 chars + NUL; IPv4 with port 21; NONE 4. Every write is snprintf( buffer, NETCODE_MAX_ADDRESS_STRING_LENGTH, ... ) or inet_ntop( ..., buffer, NETCODE_MAX_ADDRESS_STRING_LENGTH ), so the documented "at least 256, always null terminated, truncated not overflowed" holds. Placement: STYLE-C §3, "In the header, a longer explanation is a /* */ block placed after the declaration it explains" — matches netcode.h:191-195; the constant sits beside NETCODE_MAX_PACKET_SIZE with the public constants (STYLE-C §1, "then the public constants").
Mirrors: netcode.go has a private maxAddressStringLength = 256 (netcode.go:69), netcode.cs and netcode.rs expose no such constant. No port change owed for #3. grep of README.md, STANDARD.md, BUILDING.md and mas-bandwidth/standard finds no list of public constants to update.
4. Console RNG docs — claim true, placement right
sodium/sodium.c:1407 is #elif defined(__ORBIS__) || defined(__PROSPERO__); the stub is randombytes_sysrandom_buf at :1420-1423, body empty, and randombytes_buf at :1873-1879 dispatches to it via implementation->buf. randombytes_set_implementation (:1777) can be called at any time; "before netcode_init" is the safe advice since netcode_init -> sodium_init installs the default via randombytes_init_if_needed. IMPLEMENTERS.md "## 4." follows the existing 1-3; NOTES.md section sits before "## Validation". No code change, none owed.
NOTE — public API change not named for the mirror (needs a line in the PR body)
Rule (this read's brief): a public API change is named in the PR body and mirrored wherever the family mirrors it. The body names the two new codes but says nothing about netcode.cs, which mirrors both enums by value: netcode.cs/src/Client.cs:41-56 (ClientCreateError, 0-5) and src/Server.cs:21-36 (ServerCreateError, 0-6). That mirror already lacks ALLOCATE_*_FAILED (6 and 7 on main); this PR widens the gap to two codes each. The same defect class is in the C# port unguarded: Client.cs:496 _config.ReceivePacketOverride!(...), Client.cs:729 _config.SendLoopbackPacket!(...), Server.cs:881 ReceivePacketOverride!. Ask: file the netcode.cs follow-up (enum values 6/7 and 7/8, plus the create-time and loopback-entry refusals) and cite it in the PR body before merge. netcode.rs and netcode.go do not mirror the enum.
What I ran
Fresh clone at b5091c5. cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel (0 warnings), then once: ctest --test-dir build --output-on-failure -> 1/1 Test #1: netcode_test ... Passed 0.71 sec, 100% tests passed out of 1. Debug build once (-DCMAKE_BUILD_TYPE=Debug): *** ALL TESTS PASSED ***. Three single-hunk reverts rebuilt with --target netcode_test (note: --target test is CMake's ctest alias and rebuilds nothing). Header-only compile of the PR body's caller.c against both headers. Shallow clones of netcode.cs, netcode.rs, netcode.go, reliable and standard for the mirror and rule checks.
Rowan's Fable cold read, head b5091c5
… points STYLE-C section 5, "both, on purpose": the two send_loopback_packet_callback guards now netcode_assert as well as returning, so the contract breach shows in a debugger and is still handled in release. test_loopback_callback_required drives the null case, so it installs the suite's assert-swallowing handler the way test_runtime_guards does, and restores the default at the end. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames
left a comment
There was a problem hiding this comment.
Delta read at 32909b26bc75ec672be0b3f1b6875238c2243b54 (+13/-2 against b5091c5):
- Loopback assertions verified:
netcode_assert( client->config.send_loopback_packet_callback );andnetcode_assert( server->config.send_loopback_packet_callback );properly accompany the releaseif + logper STYLE-C §5.test_loopback_callback_requiredcorrectly intercepts the assertion viatest_runtime_guards_assert_handlerand restoresnetcode_default_assert_handler. - Standing finding remains:
test_network_simulator_allocation_failurestill omitsfree( receive_packet_data[0] );afternetcode_network_simulator_receive_packets, causing CI job sanitizers (asan+ubsan) #103729601182 to fail under LeakSanitizer. - Repair: Add
free( receive_packet_data[0] );immediately after line 7565 innetcode.c.
Verdict: HOLD pending the leak repair.
…n test netcode_network_simulator_receive_packets hands the buffer to the caller. test_network_simulator_allocation_failure received one packet and never freed it, which LeakSanitizer caught as a direct leak of 256 bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames
left a comment
There was a problem hiding this comment.
Delta read at 200412b605dacd708ea4bf8312169829812fba5a (+4/-0 against 32909b2):
- Leak fix verified:
test_toggle_free_function( NULL, receive_packet_data[0] );added at line 7572 beforenetcode_network_simulator_destroy. - All CI checks green: Run 34759566706 passed all 19 matrix jobs, including
sanitizers (asan+ubsan)(Job 103729854849). - All items complete:
receive_packet_overrideclient and server create-time refusal (codes 7 and 8).send_loopback_packet_callbackguarded by debug assert + release check & log at entry points.- Network simulator allocation check and graceful drop.
NETCODE_MAX_ADDRESS_STRING_LENGTHpublic contract.- Console RNG platform requirement documented.
- Clean tests across all configurations with zero leaks.
Clean to merge. APPROVE.
Robustness batch from the 2026-09-13 audit. Four findings from issue #190, taken in the issue's order, each judged against the tree at
origin/mainrather than against the issue's prose. No wire format change; nothing here is reachable from unauthenticated network input.Write-side checks in this family stay debug-only asserts. Everything added below is a release
ifplus a log line, because each guards a public entry point against a caller's configuration error — the same linenetcode_client_send_packetalready draws atnetcode.c:3511.1. Callback-NULL class — REAL, both halves
1a.
receive_packet_overridecalled with no NULL checkConfirmed at
netcode.c:3263(client) andnetcode.c:5039(server): underif ( config.override_send_and_receive )the pointer is called directly. Its twin is checked —netcode.c:2768callssend_packet_overrideonlyelse if ( send_packet_override )— so the asymmetry is real and it crashes in debug and release alike.Fix (the issue's preferred shape, setup-time):
netcode_client_create_dualandnetcode_server_create_dualnow refuse a config whereoverride_send_and_receiveis set and either override is NULL, with two new create error codes,NETCODE_CLIENT_CREATE_ERROR_MISSING_OVERRIDE_CALLBACK 7andNETCODE_SERVER_CREATE_ERROR_MISSING_OVERRIDE_CALLBACK 8. One check in each create path; the call sites are then unreachable with a NULL pointer by construction.Test: the new blocks in
test_client_create_error/test_server_create_error.Red (at
origin/main+ the tests):Green: both pass, below.
1b.
send_loopback_packet_callbackcalled with no guardConfirmed at
netcode.c:3534(client, no assert at all) andnetcode.c:5255(server,netcode_assertonly, so nothing in release).Create time cannot see this one — loopback is entered later and is optional — so the setup point is the entry into loopback itself:
netcode_client_connect_loopbackandnetcode_server_connect_loopback_client. Both now refuse, in every build, when the callback is unset. That closes both call sites:client->loopbackis set only in the first,server->client_loopback[i]only in the second.No
netcode_assertwas added beside these twoifs on purpose: the new test drives exactly this condition and the suite runs in debug as well as release.Test: new
test_loopback_callback_required.Red:
(With the check removed the same test reaches the send and takes the null call.)
2. Network simulator: unchecked allocation — REAL
Confirmed at
netcode.c:2630-2631: the result ofallocate_functionismemcpy'd into with no NULL check, and it is the only unchecked config allocation in the file.Fix: allocate into a local first and return on failure, so a failed allocation drops the new packet and leaves the entry already queued in that ring slot intact, rather than freeing it and copying into NULL.
Test: new
test_network_simulator_allocation_failure— a toggled allocator that fails for one send, then a check that nothing was delivered and that the simulator still delivers once the allocator recovers.Red (release build, single-test driver):
139 is SIGSEGV: the
memcpyinto NULL.3.
netcode_address_to_stringbuffer contract — REALConfirmed:
NETCODE_MAX_ADDRESS_STRING_LENGTHwas defined atnetcode.c:50, not innetcode.h, while the function is declared atnetcode.h:185. The internal writes do truncate (inet_ntopatnetcode.c:340,snprintfat:353,:361,:370,:380), so there is no overflow inside — the defect is that a public-header-only caller cannot name the size it must allocate.Fix: the constant moves to
netcode.hbesideNETCODE_MAX_PACKET_SIZE; the contract is documented in a/* */block after the declaration. Signature unchanged, so the ports that mirror it are unaffected.The red here is a compile, not a unit test — a header-only translation unit:
Red, against
origin/main'snetcode.h:Green, against this branch's
netcode.h: compiles, rc=0.4. Console RNG — REAL, documentation only
Confirmed at
sodium/sodium.c:1407: under#elif defined(__ORBIS__) || defined(__PROSPERO__),randombytes_sysrandom_buf(void * const buf, const size_t size)has an empty body — it never writesbuf. That is upstream libsodium's behaviour, not a local edit, and netcode takes every key and nonce fromrandombytes_buf.Not testable from this tree (no console target in CI), so it is documented: a new "Console platforms need their own RNG" section in
sodium/NOTES.md, and a new finding 4 inIMPLEMENTERS.mdfor ports whose crypto library has the same trap.Not changed
Negative
timeout_secondsand connection-response type-3 nonce reuse — both named in the issue as out of scope, and both still matchSTANDARD.md. No refactors, no style sweeps, nothing else touched.Whole suite, once, at the end
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel && ctest --test-dir build --output-on-failure— under 2 minutes (build and test together well under a minute on this bench), no new warnings.53 test functions, including
test_network_simulator_allocation_failureandtest_loopback_callback_required. The suite was also run from a Debug build (-DCMAKE_BUILD_TYPE=Debug), where the asserts are live:*** ALL TESTS PASSED ***.Reads owed: one Fable cold read and one read from another line before merge.
C# mirror owed: netcode.cs#7.
🤖 Generated with Claude Code