diff --git a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs index 3967cdb..bc2f4b1 100644 --- a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs +++ b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs @@ -154,15 +154,17 @@ async fn dynamic_data_scripts_execute_before_chained_load_handlers() { window.__dataScriptState = state; var a = document.createElement('script'); - a.src = 'data:text/javascript,window.__dataScriptState.aExec=true'; + a.src = 'data:,window.__dataScriptState.aExec=true'; a.onload = function () { state.aLoad = true; var b = document.createElement('script'); - b.src = 'data:application/javascript,' + encodeURIComponent('window.__dataScriptState.bExec=true'); + b.src = 'data:text/html,' + encodeURIComponent('window.__dataScriptState.bExec=true'); b.onload = function () { state.bLoad = true; var c = document.createElement('script'); - c.src = 'data:text/javascript;base64,' + btoa('window.__dataScriptState.cExec=true'); + c.src = 'data:text/javascript;base64,' + + btoa('window.__dataScriptState.cExec=true').replace(/=+$/, '') + + '#ignored-fragment'; c.onload = function () { state.cLoad = true; }; document.head.appendChild(c); }; @@ -196,3 +198,53 @@ async fn dynamic_data_scripts_execute_before_chained_load_handlers() { "data URL script bodies must execute before each chained load handler" ); } + +#[tokio::test(flavor = "current_thread")] +async fn invalid_dynamic_data_script_fires_error_not_load() { + 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); + + cdp( + &mut ctx, + 1, + "Page.navigate", + json!({"url": "data:text/html,", "waitUntil": "load"}), + session_id, + ) + .await; + + cdp( + &mut ctx, + 2, + "Runtime.callFunctionOn", + json!({ + "functionDeclaration": r#"function () { + window.__invalidDataScript = { error: false, load: false }; + var script = document.createElement('script'); + script.src = 'data:text/javascript;base64,!'; + script.onerror = function () { window.__invalidDataScript.error = true; }; + script.onload = function () { window.__invalidDataScript.load = true; }; + document.head.appendChild(script); + }"#, + "awaitPromise": true, + }), + session_id, + ) + .await; + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let result = cdp( + &mut ctx, + 3, + "Runtime.evaluate", + json!({ + "expression": "JSON.stringify(window.__invalidDataScript)", + "returnByValue": true, + }), + session_id, + ) + .await; + assert_eq!(result["result"]["value"], r#"{"error":true,"load":false}"#); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index cc17415..f9a7383 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -164,35 +164,49 @@ let _fpSeed = 0; let __dynScriptQueue = []; let __dynScriptBusy = false; function _decodeDataScriptUrl(url) { - let parsed; - try { parsed = new URL(url); } - catch(e) { throw new TypeError('Invalid dynamic script data URL'); } - - const path = parsed.pathname; - const comma = path.indexOf(','); - if (comma < 0) throw new TypeError('Invalid dynamic script data URL'); - - const parts = path.slice(0, comma).split(';'); - const mime = parts.shift().trim().toLowerCase(); - const isJavaScript = mime === 'application/ecmascript' || - mime === 'application/javascript' || mime === 'application/x-ecmascript' || - mime === 'application/x-javascript' || mime === 'text/ecmascript' || - mime === 'text/javascript' || /^text\/javascript1\.[0-5]$/.test(mime) || - mime === 'text/jscript' || mime === 'text/livescript' || - mime === 'text/x-ecmascript' || mime === 'text/x-javascript'; - if (!isJavaScript) throw new TypeError('Unsupported dynamic script data URL MIME type'); - - let payload = path.slice(comma + 1); - try { payload = decodeURIComponent(payload); } - catch(e) { throw new TypeError('Invalid dynamic script data URL encoding'); } - - const isBase64 = parts.some((part) => part.trim().toLowerCase() === 'base64'); - if (!isBase64) return payload; - const clean = payload.replace(/[\r\n\s]/g, ''); - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(clean)) { - throw new TypeError('Invalid dynamic script data URL base64'); + const comma = url.indexOf(','); + if (!url.startsWith('data:') || comma < 5) { + throw new TypeError('Invalid dynamic script data URL'); } - return new TextDecoder().decode(_base64ToUint8Array(clean)); + + const meta = url.slice(5, comma); + const fragment = url.indexOf('#', comma + 1); + const payload = url.slice(comma + 1, fragment < 0 ? url.length : fragment); + if (meta.split(';').some(part => part.toLowerCase() === 'base64')) { + let encoded = payload.replace(/[\r\n\t\f ]/g, ''); + const remainder = encoded.length % 4; + if (remainder === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || /=/.test(encoded.slice(0, -2))) { + throw new TypeError('Invalid dynamic script data URL base64'); + } + if (remainder > 0) encoded += '='.repeat(4 - remainder); + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { + throw new TypeError('Invalid dynamic script data URL base64'); + } + return new TextDecoder().decode(_base64ToUint8Array(encoded)); + } + + const bytes = []; + for (let i = 0; i < payload.length; i++) { + const code = payload.charCodeAt(i); + if (code === 0x25 && i + 2 < payload.length) { + const hi = _hexv(payload.charCodeAt(i + 1)); + const lo = _hexv(payload.charCodeAt(i + 2)); + if (hi >= 0 && lo >= 0) { + bytes.push(hi * 16 + lo); + i += 2; + continue; + } + } + if (code < 0x80) { + bytes.push(code); + } else { + const character = String.fromCodePoint(payload.codePointAt(i)); + if (character.length === 2) i++; + const encoded = new TextEncoder().encode(character); + for (let j = 0; j < encoded.length; j++) bytes.push(encoded[j]); + } + } + return new TextDecoder().decode(new Uint8Array(bytes)); } async function __processDynScriptQueue() { if (__dynScriptBusy) return;