diff --git a/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs b/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs new file mode 100644 index 0000000..e234b10 --- /dev/null +++ b/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs @@ -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 = ""; + 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::(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::(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::(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::(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"); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 015adad..0625061 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -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);