Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/runtime/crypto/pwhash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ pub(crate) mod argon2 {
#[derive(Copy, Clone, Default)]
pub(crate) struct VerifyOptions;

/// rust-argon2 allocates its `m` KiB block matrix infallibly (`Memory::new`), so
/// reserve that much fallibly first and report `OutOfMemory` instead of aborting.
Comment on lines +99 to +100

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

pub(crate) fn check_memory_is_allocatable(mem_cost_kib: u32) -> Result<(), Error> {
let bytes = (mem_cost_kib as usize)
.checked_mul(1024)
.ok_or(bun_alloc::AllocError)?;
let mut probe: Vec<u8> = Vec::new();
probe
.try_reserve_exact(bytes)
.map_err(|_| bun_alloc::AllocError)?;
Comment on lines +105 to +108

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.

🟣 pre-existing, not blocking: pre-existing: on Linux with the default overcommit setting, a memoryCost larger than RAM plus swap still gets the process OOM-killed instead of the promised "OutOfMemory" error. The probe at src/runtime/crypto/pwhash.rs:105-108 goes through the mimalloc global allocator, and upstream mimalloc mmaps with MAP_NORESERVE whenever /proc/sys/vm/overcommit_memory is 0 or 1, which makes the kernel skip the heuristic check, so the reservation succeeds and rust-argon2 then fills the matrix until the OOM killer fires. Fix: make the probe fail for any cost that cannot be backed on Linux, e.g. probe with a direct mmap without MAP_NORESERVE, on both the hash and verify paths.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user on a stock Linux box (vm.overcommit_memory=0, e.g. 16 GiB RAM, no swap) calls Bun.password.hashSync("pw", { algorithm: "argon2id", memoryCost: 67108864 }) meaning 64 MiB but passing bytes, which argon2 reads as 64 GiB. src/runtime/crypto/PasswordObject.rs:137 accepts any integer up to 4294967295, so str_hash reaches check_memory_is_allocatable at src/runtime/crypto/pwhash.rs:168. The probe's try_reserve_exact at pwhash.rs:107 calls bun_alloc::Mimalloc::alloc (src/bun_alloc/lib.rs:536), i.e. mi_malloc; mimalloc's unix prim adds MAP_NORESERVE to every mmap when it detects overcommit_memory 0 or 1, and the kernel only applies the mode-0 heuristic to accountable mappings, so the 64 GiB reservation succeeds and the probe returns Ok. vendor::hash_encoded at pwhash.rs:169 then allocates the same 64 GiB and writes every block, and the process is SIGKILLed by the OOM killer with no JS error and no crash report. This is what the base branch does too, so the change fixes only the RLIMIT_AS and Windows-commit cases.

Verification: pre-existing (the base already OOM-kills by the same route). The trigger is a non-ASAN Linux build with vm.overcommit_memory=0 and a memoryCost larger than RAM+swap. src/runtime/crypto/pwhash.rs:105-108 probes with try_reserve_exact through bun_alloc::Mimalloc, and upstream mimalloc's unix_mmap adds MAP_NORESERVE, so the reservation succeeds and the process is OOM-killed while the blocks are written.

Ok(())
}
Comment on lines +101 to +110

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.

🟣 pre-existing, not blocking: pre-existing: callers of node:crypto's crypto.argon2 / argon2Sync still abort the process with "memory allocation of N bytes failed" when the block matrix cannot be allocated, the exact failure this PR fixes for Bun.password. The sibling site src/runtime/node/node_crypto_binding.rs:1459 drives the same crate's hash_raw infallibly, guarded only by synthetic_allocation_limit (default u32::MAX bytes), so any memory below 4 GiB that mmap refuses still crashes. Fix: call check_memory_is_allocatable(self.memory) before hash_raw at node_crypto_binding.rs:1459 and route failure into the existing failed path. The PR description says this binding is only proposed in #37015, but it is present in this checkout at src/js/node/crypto.ts:368.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls crypto.argon2Sync("argon2id", { message, nonce, memory: 4194303, ... }) from node:crypto (src/js/node/crypto.ts:368/389) in a process whose address space or commit is limited, e.g. under ulimit -v 4194304 as in the PR's own repro. node_crypto_binding.rs:1421-1423 only pre-fails when memory*1024 exceeds synthetic_allocation_limit(), which defaults to u32::MAX (src/jsc/VirtualMachine.rs:44-45), so 4 GiB passes the check. node_crypto_binding.rs:1459 then calls rust_argon2::hash_raw, whose Memory::new does vec![Block::zero(); n] infallibly; the mmap fails and Rust's handle_alloc_error aborts the process with "memory allocation of 4294967296 bytes failed". On the base branch this aborts identically, so it is pre-existing; but the PR adds a pub(crate) probe explicitly meant for this binding while stating the binding does not exist yet, leaving the same crate driven by two callers with different failure behavior.

Verification: src/runtime/node/node_crypto_binding.rs:1421-1423 is the only pre-check, and SYNTHETIC_ALLOCATION_LIMIT defaults to u32::MAX as usize (src/jsc/VirtualMachine.rs:44-45). run() then calls rust_argon2::hash_raw (node_crypto_binding.rs:1459), and the file's own comment at 1416-1417 states rust-argon2's vec!&#91;Block::zero(); mem_cost] allocates infallibly.


fn map_err(e: &vendor::Error) -> Error {
use vendor::Error as E;
match e {
Expand Down Expand Up @@ -152,6 +165,7 @@ pub(crate) mod argon2 {
version: vendor::Version::Version13,
};

check_memory_is_allocatable(config.mem_cost)?;
let encoded = vendor::hash_encoded(password, &salt, &config).map_err(|e| map_err(&e))?;
let bytes = encoded.as_bytes();

Expand Down Expand Up @@ -211,6 +225,7 @@ pub(crate) mod argon2 {
}
};

let mut memory_cost: Option<u32> = None;
if let Some(after_dollar) = normalised.as_bytes().strip_prefix(b"$") {
if let Some(sep) = strings::index_of_char_usize(after_dollar, b'$') {
let mut rest = &after_dollar[sep + 1..];
Expand Down Expand Up @@ -244,9 +259,16 @@ pub(crate) mod argon2 {
if value > limit {
return Err(crate::Error::WeakParameters);
}
if key == b"m" {
memory_cost = Some(value);
}
}
}
}
// Strings without exactly one `m=` fail to decode below, before allocating.
if let Some(memory_cost) = memory_cost {
check_memory_is_allocatable(memory_cost)?;
Comment on lines +268 to +270

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.

🟡 (optional) Callers verifying a malformed argon2 string on a memory-constrained host now get "OutOfMemory" where the base returned "InvalidEncoding", and every verify now attempts a reservation before the string is validated. At src/runtime/crypto/pwhash.rs:270 the probe runs on the prescanned m= before vendor::verify_encoded at :273 decodes salt, hash and field layout. A garbage string like $argon2id$v=19$m=4194304,t=1,p=1$bad$bad triggers a 4 GiB reservation attempt that the base never made. Fix: validate before allocating — decode the PHC string (or at least confirm it parses) first and probe only for strings that would otherwise reach Memory::new, so InvalidEncoding stays InvalidEncoding.

Why this was flagged

Input: any verify string with a parseable m= at or below MAX_VERIFY_MEMORY_COST (src/runtime/crypto/pwhash.rs:49, 4194304 KiB) but otherwise undecodable (bad base64 salt/hash, wrong segment count), reaching str_verify via PasswordObject.rs:352 from Bun.password.verify/verifySync. The prescan at :262-264 records m, and :270 calls check_memory_is_allocatable before :273 runs the decoder. On the base branch the same string reaches vendor::verify_encoded directly, fails in decode_string before Memory::new, and the caller gets InvalidEncoding with no allocation. After the change, on a host where a 4 GiB reservation fails, the caller instead gets Password verification failed with error "OutOfMemory" with code PASSWORD_OUT_OF_MEMORY, so code that treats InvalidEncoding as a corrupt stored hash misclassifies it as a transient resource error. The comment at :268 ("fail to decode below, before allocating") describes the base ordering, not the new one, since the probe itself is the allocation.

Verification: The prescan records m at src/runtime/crypto/pwhash.rs:262-264, and :269-270 calls check_memory_is_allocatable(memory_cost)? before vendor::verify_encoded at :273. On the base branch rust-argon2's decode_string ran before Memory::new, so a malformed string returned Error::InvalidEncoding without ever allocating. Result: a memory-constrained host now reports OutOfMemory instead of InvalidEncoding.

}

match vendor::verify_encoded(&normalised, password) {
Ok(true) => Ok(()),
Expand Down
82 changes: 81 additions & 1 deletion test/js/bun/util/password.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { password } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, isDebug } from "harness";
import { bunEnv, bunExe, isASAN, isDebug, isLinux } from "harness";

const placeholder = "hey";

Expand Down Expand Up @@ -533,6 +533,86 @@ test("verify rejects encoded argon2 hashes with cost parameters above the suppor
await expect(password.verify("correct horse", junkMemory)).rejects.toThrow("InvalidEncoding");
});

// argon2 allocates memoryCost KiB up front. When the system refuses that
// allocation (memoryCost can go up to 4 TiB, and verify takes it from the
// encoded hash) the result has to be the same error every other argon2 failure
// produces, not a process abort. Both variants below run the same script in a
// child whose allocations are made to fail: under ASAN through its
// per-allocation cap, and on Linux release builds through a real address-space
// limit, which makes mimalloc itself return null.
describe("argon2 memory that cannot be allocated", () => {
const script = (memoryCost: number) => /* js */ `
const { password } = Bun;
const describeError = e => ({ name: e.name, code: e.code, message: e.message });
const options = { algorithm: "argon2id", memoryCost: ${memoryCost}, timeCost: 1 };
const small = { algorithm: "argon2id", memoryCost: 8, timeCost: 1 };
// A well-formed argon2id hash whose m= claims the unallocatable cost.
const encoded = password.hashSync("hunter2", small).replace("$m=8,", "$m=${memoryCost},");
const results = {};
try {
password.hashSync("hunter2", options);
results.hashSync = "resolved";
} catch (e) {
results.hashSync = describeError(e);
}
results.hash = await password.hash("hunter2", options).then(() => "resolved", describeError);
try {
results.verifySync = password.verifySync("hunter2", encoded);
} catch (e) {
results.verifySync = describeError(e);
}
results.verify = await password.verify("hunter2", encoded).then(v => v, describeError);
// Costs that do fit still work in this process afterwards.
results.afterwards = await password.verify("hunter2", await password.hash("hunter2", small));
console.log(JSON.stringify(results));
`;
const outOfMemory = (verb: string) => ({
name: "Error",
code: "PASSWORD_OUT_OF_MEMORY",
message: `Password ${verb} failed with error "OutOfMemory"`,
});
const expected = {
hashSync: outOfMemory("hashing"),
hash: outOfMemory("hashing"),
verifySync: outOfMemory("verification"),
verify: outOfMemory("verification"),
afterwards: true,
};

async function runChild(cmd: string[], env: NodeJS.Dict<string>) {
await using proc = Bun.spawn({ cmd, env, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// stderr is only informational: ASAN warns about every refused allocation.
return { result: JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode })), exitCode };
}

test.skipIf(!isASAN)("is reported as PASSWORD_OUT_OF_MEMORY (ASAN allocation cap)", async () => {
// 65536 KiB (64 MiB) is also the default memoryCost; the cap refuses it.
const { result, exitCode } = await runChild([bunExe(), "-e", script(65536)], {
...bunEnv,
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1", "max_allocation_size_mb=32"]
.filter(Boolean)
.join(":"),
});
expect(result).toEqual(expected);
expect(exitCode).toBe(0);
});

test.skipIf(isASAN || !isLinux)("is reported as PASSWORD_OUT_OF_MEMORY (address-space limit)", async () => {
// A 4 GiB block (the largest cost verify accepts) can never fit in a process
// whose whole address space is limited to 4 GiB, while bun itself starts fine
// with far less. The limit applies before any page is touched, so nothing is
// actually consumed; an ASAN build cannot start under it, hence the skip.
const gib = 1024 * 1024; // in KiB, the unit of both ulimit -v and memoryCost
const { result, exitCode } = await runChild(
["/bin/sh", "-c", `ulimit -v ${4 * gib} && exec "$0" "$@"`, bunExe(), "-e", script(4 * gib)],
bunEnv,
);
expect(result).toEqual(expected);
expect(exitCode).toBe(0);
});
});

test("verifySync reads the password buffer only after every argument has been coerced", () => {
const hashed = password.hashSync("correct horse", { algorithm: "argon2id", memoryCost: 8, timeCost: 1 });
const passwordBytes = new TextEncoder().encode("correct horse");
Expand Down
Loading