fix(js): real structuredClone so CryptoKey and buffers survive cloning (#390)

* fix(js): real structuredClone so CryptoKey and buffers survive cloning

structuredClone was JSON.parse(JSON.stringify), which silently drops
ArrayBuffer and TypedArray to {}. A cloned CryptoKey also lost its WeakMap
entry, so crypto.subtle.sign/encrypt/deriveBits threw "Argument is not a
valid CryptoKey" on the clone.

Replace the JSON fallback with a recursive cloner covering ArrayBuffer,
TypedArray, DataView, Map, Set, Date, RegExp, Error, and plain objects,
with cycle and own symbol-keyed property handling. CryptoKey opts in via a
clone hook (keyed by Symbol.toStringTag) that rebuilds the key through
makeKey, so the clone re-enters the WeakMap and stays usable.

Fixes #389.

* fix(js): structuredClone handles DataView and rejects functions and symbols

The TypedArray branch called value.slice(), which DataView lacks, so
cloning any DataView threw TypeError, a new crash in the buffers
category this feature targets. Copy the underlying buffer slice over
the view range and wrap a fresh DataView.

The function/symbol rejection lived after the primitive early-return
and was dead code, so functions and symbols were passed through by
reference instead of throwing DataCloneError (HTML structured clone).
Check first and throw.

Register ArrayBuffer, TypedArray, and DataView in the seen map so
repeated references share one clone, and move the cycle check up.

---------

Co-authored-by: SGavrl <[email protected]>
This commit is contained in:
Jim Zhang @ wonderhoiandSGavrl authored and GitHub committed 2026-07-13 23:21:44 +00:00
1 parent 7e2cabf411
commit a92166897f
2 files changed
+302 -1

No files matched your search

@@ -0,0 +1,209 @@
// Regression parity for issue #389: Cloudflare managed challenges hang because
// bootstrap.js stubbed two structured-clone primitives the turnstile
// orchestrate VM depends on. Each case below fails on main and must pass after
// the fix:
//
// 1. `structuredClone` must preserve ArrayBuffer / TypedArray bytes (the
// JSON fallback on line 5123 serializes them to `{}`).
// 2. A `CryptoKey` must survive `structuredClone` and remain usable by
// `crypto.subtle` (the WeakMap on line 6898 is keyed by object identity,
// so a clone has no key material and throws "not a valid CryptoKey").
//
// These mirror the cdp_click_submit_parity helpers (`serve_once` / `cdp`),
// copied per the Testing-and-debugging.md guidance to reuse the pattern.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_once() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = socket.read(&mut buf).await.unwrap();
let body = "<html><body><script>window.__boot = true;</script></body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(resp.as_bytes()).await;
});
});
format!("http://{addr}/")
}
async fn cdp(
ctx: &mut CdpContext,
id: u64,
method: &str,
params: Value,
session_id: &str,
) -> Value {
let resp = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(
resp.error.is_none(),
"CDP {method} failed: {:?}",
resp.error
);
resp.result.unwrap_or_else(|| json!({}))
}
async fn eval(ctx: &mut CdpContext, id: u64, expr: &str, session_id: &str) -> Value {
cdp(
ctx,
id,
"Runtime.evaluate",
json!({"expression": expr, "returnByValue": true, "awaitPromise": true}),
session_id,
)
.await
}
async fn setup() -> (CdpContext, String) {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_once().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
cdp(
&mut ctx,
1,
"Page.navigate",
json!({"url": url, "waitUntil": "load"}),
session_id,
)
.await;
(ctx, session_id.to_string())
}
#[tokio::test(flavor = "current_thread")]
async fn structured_clone_preserves_arraybuffer_bytes() {
let (mut ctx, sid) = setup().await;
// A 4-byte view into a 4-byte buffer. The JSON fallback loses the buffer
// entirely (Uint8Array serializes to {}), so byteLength reads back as 0.
let v = eval(
&mut ctx,
2,
r#"(async () => {
const src = new Uint8Array([10, 20, 30, 40]);
const clone = structuredClone(src);
return JSON.stringify({
srcLen: src.byteLength,
cloneLen: clone.byteLength,
same: src.buffer === clone.buffer,
bytes: Array.from(clone),
});
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(val["srcLen"], 4);
assert_eq!(val["cloneLen"], 4, "structuredClone dropped the ArrayBuffer");
assert_eq!(val["same"], false, "clone must be independent, not the same buffer");
assert_eq!(val["bytes"], json!([10, 20, 30, 40]));
}
#[tokio::test(flavor = "current_thread")]
async fn cryptokey_survives_structured_clone_and_still_signs() {
let (mut ctx, sid) = setup().await;
// importKey -> structuredClone -> sign with the clone. On main the clone
// has no WeakMap entry, so sign throws "Argument is not a valid CryptoKey".
let v = eval(
&mut ctx,
2,
r#"(async () => {
const key = await crypto.subtle.importKey(
"raw", new Uint8Array(32),
{ name: "HMAC", hash: "SHA-256" }, true, ["sign"]
);
const clone = structuredClone(key);
const sig = await crypto.subtle.sign("HMAC", clone, new TextEncoder().encode("abc"));
const b = new Uint8Array(sig);
return JSON.stringify({
cloneType: clone.type,
cloneTag: clone[Symbol.toStringTag],
sigLen: b.length,
});
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(val["cloneType"], "secret");
assert_eq!(val["cloneTag"], "CryptoKey");
assert_eq!(val["sigLen"], 32, "cloned CryptoKey must remain usable by crypto.subtle");
}
// DataView has no .slice() method, so the original TypedArray branch
// (`new Ctor(value.slice())`) threw `TypeError: value.slice is not a function`
// on every DataView clone. That is a new crash in the very buffers category
// this feature targets, so it must clone cleanly.
#[tokio::test(flavor = "current_thread")]
async fn structured_clone_preserves_dataview() {
let (mut ctx, sid) = setup().await;
let v = eval(
&mut ctx,
2,
r#"(async () => {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setUint32(0, 0x12345678);
view.setUint32(4, 0x9abcdef0);
const clone = structuredClone(view);
return JSON.stringify({
len: clone.byteLength,
a: clone.getUint32(0),
b: clone.getUint32(4),
independent: clone.buffer !== view.buffer,
});
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(val["len"], 8, "DataView clone must keep its length");
assert_eq!(val["a"], 0x12345678);
assert_eq!(val["b"], 0x9abcdef0u64 as i64);
assert_eq!(val["independent"], true, "clone must own its buffer, not alias the source");
}
// Functions and symbols are not structured-cloneable. The original early
// `typeof !== "object"` return passed them through by reference instead of
// throwing DataCloneError, so this guards both the throw and the name.
#[tokio::test(flavor = "current_thread")]
async fn structured_clone_rejects_functions_and_symbols() {
let (mut ctx, sid) = setup().await;
let v = eval(
&mut ctx,
2,
r#"(async () => {
const out = {};
try { structuredClone(function f(){}); out.fn = "cloned"; }
catch (e) { out.fn = e instanceof DOMException ? e.name : "TypeError:" + e.message; }
try { structuredClone(Symbol("s")); out.sym = "cloned"; }
catch (e) { out.sym = e instanceof DOMException ? e.name : "TypeError:" + e.message; }
return JSON.stringify(out);
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(val["fn"], "DataCloneError", "functions must not clone");
assert_eq!(val["sym"], "DataCloneError", "symbols must not clone");
}
+93 -1
View File
@@ -5164,7 +5164,88 @@ globalThis.Crypto = class Crypto {
}
};
globalThis.crypto = globalThis.crypto || new globalThis.Crypto();
globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v)));
// Real structured clone (not JSON). JSON.parse(JSON.stringify) silently drops
// ArrayBuffer/TypedArray (they serialize to {}), so Cloudflare's turnstile
// orchestrate loses every byte it tries to round-trip through postMessage and
// the challenge never completes (issue #389). Clone buffers, typed arrays,
// maps/sets, dates, errors, and plain objects recursively; CryptoKey and other
// types that register a clone hook (see crypto.subtle below) are routed there.
function _structuredClone(value, seen) {
// Functions and symbols are not structured-cloneable (HTML structured clone,
// DataCloneError). This must run before the primitive early-return below,
// which would otherwise pass them through by reference.
if (typeof value === "function" || typeof value === "symbol") {
throw new DOMException("Failed to execute 'structuredClone': value could not be cloned.", "DataCloneError");
}
if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return seen.get(value);
// Typed arrays: copy the underlying buffer slice. DataView has no .slice(),
// so slice its buffer over the view's range and wrap a fresh view.
if (ArrayBuffer.isView(value)) {
if (value instanceof DataView) {
const buf = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
const copy = new DataView(buf);
seen.set(value, copy);
return copy;
}
const Ctor = value.constructor;
const copy = new Ctor(value.slice());
seen.set(value, copy);
return copy;
}
if (value instanceof ArrayBuffer) {
const copy = value.slice(0);
seen.set(value, copy);
return copy;
}
if (value instanceof SharedArrayBuffer) {
return value; // transferable, not copyable
}
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
if (value instanceof Map) {
const m = new Map();
seen.set(value, m);
for (const [k, v] of value) m.set(_structuredClone(k, seen), _structuredClone(v, seen));
return m;
}
if (value instanceof Set) {
const s = new Set();
seen.set(value, s);
for (const v of value) s.add(_structuredClone(v, seen));
return s;
}
if (value instanceof Error) {
const Ctor = value.constructor || Error;
const e = new Ctor(value.message);
if (value.name) e.name = value.name;
if (value.stack) e.stack = value.stack;
if (value.cause !== undefined) e.cause = _structuredClone(value.cause, seen);
return e;
}
// Platform objects that carry internal slots opt into cloning via a hook
// (CryptoKey re-registers its key material so the clone stays usable by
// crypto.subtle). Anything else with a registered hook takes that path.
if (typeof value[Symbol.toStringTag] === "string" && globalThis.__obscura_clone_hooks) {
const hook = globalThis.__obscura_clone_hooks[value[Symbol.toStringTag]];
if (typeof hook === "function") return hook(value, seen);
}
const out = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
seen.set(value, out);
for (const k in value) {
if (Object.prototype.hasOwnProperty.call(value, k)) {
out[k] = _structuredClone(value[k], seen);
}
}
// Symbols are not enumerable via for-in; copy own symbol-keyed properties.
const syms = Object.getOwnPropertySymbols(value);
for (const s of syms) {
const d = Object.getOwnPropertyDescriptor(value, s);
if (d && "value" in d) out[s] = _structuredClone(d.value, seen);
}
return out;
}
globalThis.structuredClone = globalThis.structuredClone || ((v) => _structuredClone(v, new Map()));
globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
// WHATWG Storage as a legacy platform object: a Proxy routes property access
@@ -6958,6 +7039,17 @@ if (!globalThis.crypto.subtle) {
}
return keyMaterial.get(key);
}
// A CryptoKey cloned via structuredClone or postMessage is a different
// object, so the WeakMap lookup above misses and crypto.subtle throws
// "Argument is not a valid CryptoKey". Re-register the (cloned) key's
// material so the clone stays usable. The clone hook is dispatched by
// _structuredClone via Symbol.toStringTag ("CryptoKey"); registered lazily
// because structuredClone is defined before this block (issue #389).
globalThis.__obscura_clone_hooks = globalThis.__obscura_clone_hooks || {};
globalThis.__obscura_clone_hooks["CryptoKey"] = function (src) {
const copy = makeKey(src.type, src.extractable, src.algorithm, src.usages, keyBytes(src));
return copy;
};
const toBytes = (data) => {
if (data instanceof ArrayBuffer) return new Uint8Array(data);