Skip to content
Merged
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
31 changes: 27 additions & 4 deletions crates/perry-stdlib/src/crypto/random.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,16 @@ pub extern "C" fn js_crypto_random_bytes_buffer(

/// `crypto.randomBytes(size, callback)` — callback form.
///
/// Perry executes the callback synchronously, but preserves Node's
/// observable callback shape `(err, buffer)` for parity tests and common
/// compatibility paths.
/// Node runs `randomBytes` on the libuv threadpool, so the `(err, buffer)`
/// callback fires on a LATER event-loop iteration (one macrotask hop), never
/// synchronously. Perry generates the bytes synchronously but must preserve
/// that timing: schedule the callback via `setImmediate` instead of calling it
/// inline. Resolving it synchronously let an `await`ing caller continue a
/// macrotask early — e.g. Auth.js generates its CSRF token with async
/// `randomBytes`, so a Next.js Server Component's `await auth()` completed one
/// event-loop iteration ahead of Node, reordering the React Flight (RSC) rows
/// of the streamed response. The bytes are still produced eagerly; only the
/// callback dispatch is deferred.
#[no_mangle]
pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: f64) -> f64 {
let buf = js_crypto_random_bytes_buffer(size);
Expand All @@ -81,7 +88,23 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits:
} else {
f64::from_bits(JSValue::pointer(buf as *const u8).bits())
};
call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value);
// Only a genuine closure is a schedulable callback. The timer queue
// reinterprets the i64 as `*const ClosureHeader` and calls it with no
// validation, so a bare `is_pointer()` check is not enough: a non-function
// POINTER value (a plain object or array passed as the callback —
// `randomBytes(n, {})`, which Node rejects with ERR_INVALID_CALLBACK) would
// pass it and be miscast as a closure, then dereferenced when the timer
// fires. Gate on `is_closure_ptr` (the CLOSURE_MAGIC + heap-range probe used
// by every other node-style-callback site) so a non-callable argument is a
// safe no-op instead. `is_closure_ptr` self-rejects the whole handle band
// and any non-heap address, so a primitive/undefined callback is covered
// without a separate magnitude floor.
let cb_ptr = perry_runtime::value::js_nanbox_get_pointer(callback_bits);
if perry_runtime::closure::is_closure_ptr(cb_ptr as usize) {
let err = f64::from_bits(JSValue::null().bits());
let args = [err, value];
perry_runtime::timer::js_set_immediate_callback_args(cb_ptr, args.as_ptr(), 2);
}
f64::from_bits(JSValue::undefined().bits())
}

Expand Down
49 changes: 48 additions & 1 deletion crates/perry-stdlib/src/webcrypto/digest.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
use super::*;

/// Native closure body: resolve the captured Promise (slot 0) with the
/// captured digest value (slot 1). Scheduled via `setImmediate` so
/// `crypto.subtle.digest()` settles on a later event-loop iteration, matching
/// Node — whose WebCrypto digest runs on the libuv threadpool (a macrotask),
/// not synchronously. Resolving it synchronously let an `await`ing caller
/// continue an event-loop iteration early: Auth.js hashes its CSRF token with
/// `subtle.digest`, so a Next.js Server Component's `await auth()` completed
/// ahead of Node and reordered the React Flight (RSC) rows of the response.
extern "C" fn webcrypto_digest_settle(closure: *const perry_runtime::ClosureHeader) -> f64 {
let promise_bits = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as u64;
let value_bits = perry_runtime::closure::js_closure_get_capture_ptr(closure, 1) as u64;
// Slot 2 holds the remaining macrotask hops (raw i64). Node's threadpool
// WebCrypto digest observably yields TWO setImmediate iterations (submit +
// completion), so re-arm one more time before resolving.
let remaining = perry_runtime::closure::js_closure_get_capture_ptr(closure, 2);
if remaining > 1 {
let cl = perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3);
perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_bits as i64);
perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value_bits as i64);
perry_runtime::closure::js_closure_set_capture_ptr(cl, 2, remaining - 1);
perry_runtime::timer::js_set_immediate_callback(cl as i64);
return f64::from_bits(JSValue::undefined().bits());
}
let promise = perry_runtime::value::js_nanbox_get_pointer(f64::from_bits(promise_bits))
as *mut perry_runtime::promise::Promise;
if !promise.is_null() {
perry_runtime::promise::js_promise_resolve(promise, f64::from_bits(value_bits));
}
f64::from_bits(JSValue::undefined().bits())
}

/// `crypto.subtle.digest(algorithm, data)` → Promise<Uint8Array>
///
/// `algorithm` is "SHA-1" / "SHA-256" / "SHA-384" / "SHA-512" (string)
Expand All @@ -14,5 +45,21 @@ pub unsafe extern "C" fn js_webcrypto_digest(algo_bits: f64, data_bits: f64) ->
};
let bytes = bytes_from_jsvalue(data_bits.to_bits());
let digest = compute_digest(algo, &bytes);
resolve_with_bytes(&digest)
// The digest bytes are computed eagerly, but the Promise settles on the
// next event-loop iteration (setImmediate) — Node runs the hash on the
// threadpool, so `await subtle.digest(...)` observably yields a macrotask.
let buf = alloc_uint8array_from_slice(&digest);
if buf.is_null() {
return reject_with_dom_exception("OperationError", "The operation failed");
}
let value = f64::from_bits(JSValue::pointer(buf as *const u8).bits());
let promise = perry_runtime::promise::js_promise_new();
let promise_val = f64::from_bits(JSValue::pointer(promise as *const u8).bits());
let cl = perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3);
perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_val.to_bits() as i64);
perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value.to_bits() as i64);
// Remaining macrotask hops (Node's threadpool digest = 2 setImmediate ticks).
perry_runtime::closure::js_closure_set_capture_ptr(cl, 2, 2);
perry_runtime::timer::js_set_immediate_callback(cl as i64);
promise
Comment on lines +48 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Root heap allocations to prevent use-after-free.

The raw pointers buf and promise are left unrooted during the JS heap allocations for js_promise_new and js_closure_alloc. If a garbage collection occurs during these allocations, the unrooted objects may be swept, resulting in the closure capturing dangling pointers.

Please secure these pointers using a RuntimeHandleScope or the runtime's equivalent rooting mechanism immediately after allocation.

🤖 Prompt for 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.

In `@crates/perry-stdlib/src/webcrypto/digest.rs` around lines 49 - 66, Root the
allocated Uint8Array and Promise in the digest flow before performing subsequent
heap allocations. In the code around webcrypto_digest_settle,
alloc_uint8array_from_slice, js_promise_new, and js_closure_alloc, create the
runtime’s equivalent RuntimeHandleScope immediately after allocation and
register both buf and promise with it so they remain live while the closure and
captures are created.

}
56 changes: 56 additions & 0 deletions test-files/test_gap_webcrypto_async_threadpool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Node runs WebCrypto `subtle.digest` and async `crypto.randomBytes(cb)` on the
// libuv threadpool, so `await`ing them observably yields a macrotask — they do
// NOT resolve synchronously. Perry resolved them synchronously, which let an
// awaiting caller (e.g. Auth.js hashing a CSRF token in a Next.js Server
// Component) continue an event-loop iteration early and reorder streamed RSC
// output. The exact macrotask-hop count is threadpool/context dependent, so this
// pins the observable contract: async crypto crosses ≥1 macrotask, a sync hash
// crosses none, and values are unchanged.
import { webcrypto, randomBytes, createHash } from "crypto";
import { promisify } from "util";

let s = 0;
let run = true;
function metro(): void {
if (!run) return;
s++;
setImmediate(metro);
}
metro();

async function crossesMacrotask(fn: () => Promise<unknown>): Promise<boolean> {
const a = s;
await fn();
return s - a > 0;
}

async function main(): Promise<void> {
console.log(
"subtle.digest crosses macrotask:",
await crossesMacrotask(() =>
webcrypto.subtle.digest("SHA-256", new Uint8Array([1, 2, 3])),
),
);
console.log(
"randomBytes(cb) crosses macrotask:",
await crossesMacrotask(() => promisify(randomBytes)(16)),
);

// A synchronous hash stays synchronous.
const a2 = s;
createHash("sha256").update("x").digest("hex");
console.log("createHash sync crosses macrotask:", s - a2 > 0);

// Values are unchanged by the deferral.
const d = await webcrypto.subtle.digest(
"SHA-256",
new TextEncoder().encode("hello"),
);
console.log("sha256(hello):", Buffer.from(d).toString("hex"));
const rb = await promisify(randomBytes)(8);
console.log("randomBytes length:", rb.length);

run = false;
}

main();
Loading