Skip to content

Bun.udpSocket, Bun.password: range-check numeric options before ToInt32 narrowing - #36999

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/c52534d8/udp-password-toint32-port-wrap
Aug 6, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/c52534d8/udp-password-toint32-port-wrap

Conversation

@robobun

@robobun robobun commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Bun.udpSocket ports and Bun.password cost 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:

await Bun.udpSocket({ port: 4294967377 });              // bound port 81 (2^32 + 81)
await Bun.udpSocket({ connect: { hostname: "127.0.0.1", port: 4294967377 } }); // peer port 81
socket.send("hi", 2 ** 32 + 9, "127.0.0.1");            // delivered to port 9
await Bun.udpSocket({ port: 80.5 });                    // bound port 80
Bun.password.hashSync("pw", { algorithm: "bcrypt", cost: 4294967300 }); // "$2b$04$..." (cost 4)
Bun.password.hashSync("pw", { algorithm: "argon2id", timeCost: 2 ** 32 + 2 }); // t=2

Meanwhile { port: 65617 } correctly threw Expected "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: bind port (0..=65535), connect.port (1..=65535), and parse_addr for send/sendMany (1..=65535). parse_addr now 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 Windows send() reported success for a datagram that never went anywhere. Port 0 stays allowed inside parse_addr because the multicast membership/interface callers pass a literal 0.
  • PasswordObject.rs: bcrypt cost (4..=31), argon2 timeCost/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 and port || 0 forwarded the function to Bun.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 by test-dgram-socket-buffer-size.js and the other bind(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 proves 2 ** 32 + server.port throws instead of delivering to server.port.
  • test/js/bun/util/password.test.ts: wrap and fractional values for cost, timeCost, memoryCost across both hash and hashSync.

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

…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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The 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.

Changes

Dgram binding

Layer / File(s) Summary
Callback-only bind normalization
src/js/node/dgram.ts
Socket.prototype.bind treats a function as the callback-only overload and sets the port to null.

Numeric validation

Layer / File(s) Summary
UDP port validation
src/runtime/socket/udp_socket.rs, test/js/bun/udp/udp_socket.test.ts
UDP port parsing validates numeric range and integer status before conversion. Tests cover bind, connect, send, and sendMany cases.
Password option validation
src/runtime/crypto/PasswordObject.rs, test/js/bun/util/password.test.ts
Bcrypt and Argon2 options reject fractional, non-finite, and out-of-range values before integer conversion. Tests cover each option range.

Possibly related PRs

  • oven-sh/bun#36998: Both PRs validate numeric inputs before narrowing conversions to prevent overflow, wrapping, or truncation.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main numeric range-validation changes in Bun.udpSocket and Bun.password.
Description check ✅ Passed The description includes both required sections and clearly explains the changes, fixes, affected APIs, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Aug 5, 2026
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.
Comment thread src/js/node/dgram.ts Outdated
Comment thread src/runtime/crypto/PasswordObject.rs Outdated
Comment thread src/runtime/crypto/PasswordObject.rs Outdated
Comment thread src/runtime/crypto/PasswordObject.rs Outdated
Comment thread src/runtime/socket/udp_socket.rs Outdated
Comment thread src/runtime/socket/udp_socket.rs Outdated
Comment thread src/runtime/socket/udp_socket.rs Outdated
Comment thread src/runtime/socket/udp_socket.rs Outdated
Comment thread src/runtime/socket/udp_socket.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 4fb02aa and bc0ed75.

📒 Files selected for processing (5)
  • src/js/node/dgram.ts
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/socket/udp_socket.rs
  • test/js/bun/udp/udp_socket.test.ts
  • test/js/bun/util/password.test.ts

Comment thread src/runtime/socket/udp_socket.rs Outdated
Comment thread test/js/bun/udp/udp_socket.test.ts Outdated
Comment thread test/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.
Comment thread src/runtime/socket/udp_socket.rs
Comment thread src/runtime/socket/udp_socket.rs
Comment thread src/runtime/socket/udp_socket.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between bc0ed75 and 6258edc.

📒 Files selected for processing (3)
  • src/runtime/socket/udp_socket.rs
  • test/js/bun/udp/udp_socket.test.ts
  • test/js/bun/util/password.test.ts

Comment thread src/runtime/socket/udp_socket.rs
@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused input-validation fix that range-checks numeric options as f64 before narrowing.

What was reviewed:

  • f64 range/fract checks in PasswordObject.rs and udp_socket.rs: NaN and ±Infinity are caught (.fract() on non-finite is NaN, and NaN != 0.0 is true; range .contains() on NaN is false); as u8/u16/u32 casts are sound after the checks.
  • parse_addr refactor to take a validated u16 — membership/interface callers pass literal 0, send/sendMany go through parse_port (rejects 0), so port 0 no longer reaches sendto.
  • dgram.ts bind(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_f64 string coercion still accepts "80" (ToNumber → 80.0), so numeric-string ports keep working; u32::MAX is 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).

@Jarred-Sumner
Jarred-Sumner merged commit 8d08b97 into main Aug 6, 2026
53 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/c52534d8/udp-password-toint32-port-wrap branch August 6, 2026 11:05
cirospaciari added a commit that referenced this pull request Aug 18, 2026
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.
cirospaciari added a commit that referenced this pull request Aug 19, 2026
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.
cirospaciari added a commit that referenced this pull request Aug 19, 2026
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.
cirospaciari added a commit that referenced this pull request Aug 19, 2026
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.
cirospaciari added a commit that referenced this pull request Aug 19, 2026
)

### 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants