Bun.udpSocket, Bun.password: range-check numeric options before ToInt32 narrowing - #36999
Conversation
…32 narrowing
The port in Bun.udpSocket({ port }), connect.port, and the port argument
of send()/sendMany() were coerced through ECMAScript ToInt32 and only
range-checked afterwards, so values beyond the i32 range wrapped modulo
2^32 and dodged the check: { port: 4294967377 } bound port 81, and
send(data, 2**32 + 9, host) delivered the datagram to port 9. The same
pattern in Bun.password let bcrypt cost 4294967300 hash as cost 4 and
argon2 timeCost/memoryCost wrap the same way. Non-integer values were
silently truncated ({ port: 80.5 } bound port 80).
Validate the double as an in-range integer before narrowing instead.
parse_addr (send/sendMany) now throws for out-of-range ports rather
than rewriting them to 0, which on Windows made send() report success
for a datagram that never went anywhere; port 0 stays allowed there
because the multicast membership/interface callers pass a literal 0.
WalkthroughThe changes normalize callback-only datagram binding and validate UDP ports and password-hashing options before integer conversion. Regression tests cover fractional, non-finite, wrapped, unsafe, and out-of-range values. ChangesDgram binding
Numeric validation
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
Socket.prototype.bind(cb) kept the callback in the port variable, and 'port || 0' passed it through to Bun.udpSocket. Node forwards the same value to its native layer where ToUint32 coerces it to 0; now that Bun.udpSocket rejects non-numeric ports instead of coercing, normalize the function to null (no port) in the JS layer.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/socket/udp_socket.rs`:
- Around line 1576-1586: The port parsing logic around parse_addr currently
accepts port 0 for all callers; restrict that behavior to membership and
interface parsing only. Add an allow_zero parameter or dedicated helper, enable
it exclusively in those callers, and ensure send and send_many_impl reject zero
while preserving the 1–65535 validation. Extend the send and sendMany regression
tests with port-zero cases.
In `@test/js/bun/udp/udp_socket.test.ts`:
- Around line 109-125: The test currently creates client outside the cleanup
scope, leaving server open if client creation fails. Move client creation into
the existing try/finally flow around the test, or use nested cleanup so
server.close() always runs while client.close() only runs after successful
creation.
In `@test/js/bun/util/password.test.ts`:
- Around line 142-186: Add assertions in the existing “cost values are
range-checked before ToInt32 narrowing” test for Argon2 `timeCost` and
`memoryCost` with `NaN`, `-Infinity`, and `Infinity`. Each case should call
`hash` with the corresponding algorithm and option and assert the existing
field-specific validation error, covering both non-finite and range checks.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 44851981-4572-4885-b91a-ba0f68e4586d
📒 Files selected for processing (5)
src/js/node/dgram.tssrc/runtime/crypto/PasswordObject.rssrc/runtime/socket/udp_socket.rstest/js/bun/udp/udp_socket.test.tstest/js/bun/util/password.test.ts
…addr Node's validatePort rejects 0 for send (allowZero: false); keeping it meant a datagram to port 0 silently reported success on Windows. The membership/interface callers now pass a validated u16 0 directly instead of a dummy JSValue, so parse_addr no longer needs a port-0 carve-out. Also covers argon2 non-finite costs in tests and scopes the send test's socket cleanup.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/socket/udp_socket.rs`:
- Around line 1542-1552: Update UDPSocket::js_connect to validate its port
argument through parse_port before narrowing or initiating any socket side
effects, replacing the existing as_int32 conversion. Preserve valid integer
ports while rejecting fractional, wrapped/out-of-range, zero, NaN, and infinite
values; add dgram.connect() coverage for these cases.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 68a44651-5e1d-40e4-8c93-8b610cbcebc4
📒 Files selected for processing (3)
src/runtime/socket/udp_socket.rstest/js/bun/udp/udp_socket.test.tstest/js/bun/util/password.test.ts
|
CI status on 6258edc: every test this PR touches is green on all lanes (udp, dgram, password suites). The remaining failures are unrelated: the x64-asan lane fails on an RSA keygen leak in test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts that also fails on main (reported separately), and the rest are known-flaky lanes that passed on retry (dns, install, dlopen). Ready for review. |
There was a problem hiding this comment.
LGTM — focused input-validation fix that range-checks numeric options as f64 before narrowing.
What was reviewed:
- f64 range/fract checks in
PasswordObject.rsandudp_socket.rs: NaN and ±Infinity are caught (.fract()on non-finite is NaN, andNaN != 0.0is true; range.contains()on NaN is false);as u8/u16/u32casts are sound after the checks. parse_addrrefactor to take a validatedu16— membership/interface callers pass literal 0, send/sendMany go throughparse_port(rejects 0), so port 0 no longer reaches sendto.dgram.tsbind(cb) normalization — needed because the stricter native check would otherwise see the callback as the port and throw; matches Node's overload.- Ruled out:
coerce_f64string coercion still accepts "80" (ToNumber → 80.0), so numeric-string ports keep working;u32::MAXis exactly representable in f64 so the upper-bound compare is exact.
Extended reasoning...
Overview
The PR fixes silent ToInt32 wrapping in Bun.udpSocket port options (bind, connect, send/sendMany) and Bun.password cost options (bcrypt cost, argon2 timeCost/memoryCost). Values like 2**32 + 81 previously wrapped to 81 and passed the post-narrowing range check; now the f64 is validated first (fract() == 0 and in-range) before the as cast. parse_addr was refactored to take an already-validated u16 port, with a new parse_port helper for the send paths, so the port-0 special case for membership callers becomes a plain literal instead of a branch. A one-line fix in dgram.ts normalizes the bind(cb) overload so the callback isn't forwarded as the port (which the old ToInt32 path silently coerced to 0).
Security risks
None introduced. The change strictly tightens input validation on user-supplied numbers — wrapped/fractional/non-finite values now throw instead of silently binding/sending to an unintended port or hashing with a truncated cost parameter. This is a mild security improvement (a wrapped port could have sent data to port 81 when the caller wrote 2**32 + 81).
Level of scrutiny
Medium. Native code touching JSC value coercion and sockaddr construction, but the changes are localized to the validation step and don't touch memory layout, lifetime, or the send path itself. I verified each f64 check handles NaN and ±Infinity correctly (Rust's f64::fract() on non-finite yields NaN, and NaN != 0.0 is true; RangeInclusive::contains(&NaN) is false). The as u16/as u8/as u32 casts are sound because the preceding range check guarantees the value is an in-range integer, and u32::MAX is exactly representable in f64.
Other factors
All prior review feedback (comment-cop long-comment flags, CodeRabbit's port-0 restriction, test cleanup scoping, non-finite argon2 cases) has been addressed and the threads are resolved. The one remaining CodeRabbit note about js_connect's as_int32() was withdrawn — that path is guarded by validatePort in JS and is the subject of a separate open PR. Test coverage is thorough: wrap values on both sides of zero, 2**53, fractional, NaN, ±Infinity, and port 0 for send/sendMany. The send/sendMany behavior change (throw instead of silent clamp-to-0) is the intended fix and matches Node's validatePort(allowZero: false).
Before the Rust port, `Bun.password.hash(pw, { memoryCost: 1..7 })`
worked and produced `$argon2id$v=19$m=<n>,...` strings (the docs use
`memoryCost: 4`). The port first clamped these to `m=8` (#30960) and
then rejected them (#36999). With rust-argon2 now vendored without the
`m >= 8 * lanes` floor, hashing can accept them again: `m` is encoded
as given and computed with the padded 8 blocks, byte-compatible with
hashes Bun 1.3.x produced and verified.
The lower bound goes back to `memoryCost >= 1`; the error messages
match the pre-port wording.
Before the Rust port, `Bun.password.hash(pw, { memoryCost: 1..7 })`
worked and produced `$argon2id$v=19$m=<n>,...` strings (the docs use
`memoryCost: 4`). The port first clamped these to `m=8` (#30960) and
then rejected them (#36999). With rust-argon2 now vendored without the
`m >= 8 * lanes` floor, hashing can accept them again: `m` is encoded
as given and computed with the padded 8 blocks, byte-compatible with
hashes Bun 1.3.x produced and verified.
The lower bound goes back to `memoryCost >= 1`; the error messages
match the pre-port wording.
Before the Rust port, `Bun.password.hash(pw, { memoryCost: 1..7 })`
worked and produced `$argon2id$v=19$m=<n>,...` strings (the docs use
`memoryCost: 4`). The port first clamped these to `m=8` (#30960) and
then rejected them (#36999). With rust-argon2 now vendored without the
`m >= 8 * lanes` floor, hashing can accept them again: `m` is encoded
as given and computed with the padded 8 blocks, byte-compatible with
hashes Bun 1.3.x produced and verified.
The lower bound goes back to `memoryCost >= 1`; the error messages
match the pre-port wording.
Before the Rust port, `Bun.password.hash(pw, { memoryCost: 1..7 })`
worked and produced `$argon2id$v=19$m=<n>,...` strings (the docs use
`memoryCost: 4`). The port first clamped these to `m=8` (#30960) and
then rejected them (#36999). With rust-argon2 now vendored without the
`m >= 8 * lanes` floor, hashing can accept them again: `m` is encoded
as given and computed with the padded 8 blocks, byte-compatible with
hashes Bun 1.3.x produced and verified.
The lower bound goes back to `memoryCost >= 1`; the error messages
match the pre-port wording.
) ### What does this PR do? `Bun.password.verify` throws `PASSWORD_WEAK_PARAMETERS` on argon2 hashes that earlier Bun versions created with `memoryCost` 1–7, so those stored hashes became unverifiable after upgrading. This restores verification of them while keeping the current rule that new hashes require `memoryCost >= 8`. Bun's Zig-era argon2 (the oven-sh/zig fork, `lib/std/crypto/argon2.zig`) had the `m >= 8 * p` check commented out, so `Bun.password.hash(pw, { memoryCost: 4 })` — common in tests and dev configs for speed — produced valid PHC strings like `$argon2id$v=19$m=4,t=2,p=1$…`. The Rust port uses `rust-argon2`, whose `Context::new` rejects `m < 8` with `MemoryTooLittle` before anything is computed. Since `m` is mixed into H0 as written, no call into the published crate can reproduce these hashes. The fix vendors `rust-argon2` 3.0.0 into `vendor/rust-argon2` through the existing deps system (fetched + patched like `lolhtml`, consumed by cargo as a path dependency) with a small patch that drops that floor. The crate already pads memory up to `2 * SYNC_POINTS * lanes` blocks and feeds the as-written `m` into H0 — exactly what the Zig implementation did — so legacy hashes verify byte-for-byte. `PasswordObject` still rejects `memoryCost < 8` when hashing (unchanged behavior from #36999). `m=0` still fails as invalid. ### How did you verify your code works? - New tests in `test/js/bun/util/password.test.ts` use hashes generated by Bun 1.3.14 with `m=4` (argon2id/i/d) and `m=1`; they fail on current main with `WeakParameters` and pass with this change (`bun bd test test/js/bun/util/password.test.ts`: 76 pass, 0 fail). - Drove the debug binary directly: the legacy hashes verify `true` for the right password and `false` for a wrong one (async + sync); `hashSync({ memoryCost: 4 })` still throws `Memory cost must be at least 8`. - Cross-checked 57 hashes generated by Bun 1.3.14 across argon2i/d/id, bcrypt and various `memoryCost`/`timeCost` values: all verify on this build (6 failed before, all `m<8`). <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/internal/rust-windows-sys-link.test.ts test/js/bun/util/password.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
What does this PR do?
Bun.udpSocketports andBun.passwordcost options were coerced through ECMAScript ToInt32 (modular wrap) and range-checked only after the wrap, so out-of-range values beyond the i32 range silently passed:Meanwhile
{ port: 65617 }correctly threwExpected "port" to be an integer between 0 and 65535, because 65617 stays in i32 range.How did you fix it?
Validate the double as an in-range integer before narrowing, at each site:
udp_socket.rs: bindport(0..=65535),connect.port(1..=65535), andparse_addrforsend/sendMany(1..=65535).parse_addrnow throws for out-of-range ports instead of rewriting them to 0: on Linux the port-0 sockaddr happened to fail with EINVAL, but on Windowssend()reported success for a datagram that never went anywhere. Port 0 stays allowed insideparse_addrbecause the multicast membership/interface callers pass a literal 0.PasswordObject.rs: bcryptcost(4..=31), argon2timeCost/memoryCost(1/8 ..= u32 max). Existing error messages are kept for the previously-rejected cases (Memory cost must be at least 8,Time cost must be greater than 0); wrapping and fractional values now throw instead of hashing with a truncated parameter.dgram.ts:Socket.prototype.bind(cb)kept the callback in its port variable andport || 0forwarded the function toBun.udpSocket, relying on the old ToInt32 coercion to turn it into 0 (Node does the same dance via ToUint32 in its native layer). Normalize a function first argument to "no port" in the JS layer; caught bytest-dgram-socket-buffer-size.jsand the otherbind(cb)tests in the Node suite.node:dgram's send/connect paths validate ports in JS (ERR_SOCKET_BAD_PORT) before reaching this code, so they were unaffected.Tests
test/js/bun/udp/udp_socket.test.ts: wrap values added to the existing connect.port case list, a new bind case list (4294967377, -4294967215, 2^53, 80.5, NaN, Infinity), and a send/sendMany test that binds a real socket and proves2 ** 32 + server.portthrows instead of delivering toserver.port.test/js/bun/util/password.test.ts: wrap and fractional values forcost,timeCost,memoryCostacross bothhashandhashSync.All fail on the released binary (10 udp + 2 password failures) and pass with this change; the full udp, dgram, and password suites plus all 75
test-dgram-*Node parallel tests pass with the debug build.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/udp/udp_socket.test.ts test/js/bun/util/password.test.ts