From f61493fbd120ce1d79424828e4fe386d57d73ceb Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:25:09 +0000 Subject: [PATCH] Reject unsuccessful script responses --- crates/obscura-browser/src/page.rs | 38 ++++++++++++++++++++++-- crates/obscura-js/js/bootstrap.js | 11 ++++++- crates/obscura-js/src/runtime.rs | 46 +++++++++++++++++++++++++++++ render-repros/paired-corpus.py | 33 ++++++++++++++++++++- render-repros/test_paired_corpus.py | 8 +++++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index e82e39c..fd92f12 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -839,7 +839,7 @@ impl Page { if let Some(js) = &mut self.js { let escaped_css = all_css.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$"); let code = format!( - "var style = document.createElement('style'); style.textContent = `{}`; document.head.appendChild(style);", + "var style = document.createElement('style'); style.setAttribute('data-obscura-external-stylesheets', ''); style.textContent = `{}`; document.head.appendChild(style);", escaped_css ); let _ = js.execute_script("", &code); @@ -1084,6 +1084,23 @@ impl Page { let mut fetched: std::collections::HashMap = std::collections::HashMap::new(); for result in fetch_results { if let Some((idx, url, resp)) = result { + if !script_response_is_executable(resp.status) { + self.record_network_event_with_body( + &url, + "GET", + "Script", + resp.status, + &resp.headers, + &resp.body, + false, + ); + tracing::warn!( + "Refusing to execute script {} after HTTP {}", + url, + resp.status + ); + continue; + } // Script bodies: only the HTTP Content-Type charset matters // (no in-band meta-charset for JS). let code = obscura_net::decode_non_html(&resp.body, resp.content_type()); @@ -2268,6 +2285,10 @@ impl Page { } } +fn script_response_is_executable(status: u16) -> bool { + (200..=299).contains(&status) +} + fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool { if pattern == "*" { return true; @@ -2299,10 +2320,23 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool { mod tests { use super::{ parse_import_url, rebase_css_urls, should_restore_hydration_snapshot, split_css_imports, - truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats, + script_response_is_executable, truncate_on_char_boundary, url_matches_cdp_pattern, + HydrationStats, }; use obscura_dom::parse_html; + #[test] + fn external_scripts_require_a_successful_http_status() { + assert!(script_response_is_executable(200)); + assert!(script_response_is_executable(204)); + assert!(script_response_is_executable(299)); + assert!(!script_response_is_executable(0)); + assert!(!script_response_is_executable(304)); + assert!(!script_response_is_executable(401)); + assert!(!script_response_is_executable(404)); + assert!(!script_response_is_executable(500)); + } + #[cfg(feature = "render")] #[test] fn page_screenshot_uses_the_live_window_scroll_offset() { diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 3ef8fe4..b9703fc 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -253,7 +253,16 @@ async function __processDynScriptQueue() { body = _decodeDataScriptUrl(task.url); } else { const raw = await Deno.core.ops.op_fetch_url(task.url, "GET", "{}", "", task.pageOrigin, "no-cors"); - body = JSON.parse(raw).body; + const parsed = JSON.parse(raw); + // The HTML script-fetch algorithm treats an unsuccessful HTTP + // response as a network error. Evaluating its response body is both + // observably unlike browsers and dangerous: JSON error payloads + // become JavaScript source, while servers can return diagnostic + // HTML that mutates global state before throwing. + if (!(parsed.status >= 200 && parsed.status <= 299)) { + throw new Error('HTTP ' + (parsed.status || 0)); + } + body = parsed.body; } if (body) { globalThis.__currentScriptNid = task.nid; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 668281f..8611f7a 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -4893,6 +4893,52 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn unsuccessful_dynamic_script_response_fires_error_without_evaluating_body() { + let mut rt = setup_runtime(""); + let result = rt + .call_function_on_for_cdp( + r#"async () => { + const originalFetchOp = Deno.core.ops.op_fetch_url; + try { + Deno.core.ops.op_fetch_url = (url) => JSON.stringify({ + status: 401, + headers: { "content-type": "application/json" }, + body: "globalThis.__executedFailedScript = true", + url, + }); + const script = document.createElement("script"); + script.src = "/unauthorized.js"; + const outcome = await new Promise(resolve => { + script.onload = () => resolve("load"); + script.onerror = () => resolve("error"); + document.head.appendChild(script); + }); + return { + outcome, + executed: globalThis.__executedFailedScript === true, + }; + } finally { + Deno.core.ops.op_fetch_url = originalFetchOp; + } + }"#, + None, + &[], + true, + true, + ) + .await + .unwrap(); + + assert_eq!( + result.value.unwrap(), + serde_json::json!({ + "outcome": "error", + "executed": false, + }) + ); + } + #[tokio::test(flavor = "current_thread")] async fn test_response_array_buffer_preserves_typed_array_view() { let mut rt = setup_runtime(""); diff --git a/render-repros/paired-corpus.py b/render-repros/paired-corpus.py index f989684..67032cf 100755 --- a/render-repros/paired-corpus.py +++ b/render-repros/paired-corpus.py @@ -125,6 +125,11 @@ def obscura_state_eval_expression(scroll): + scroll_script + "const root=document.documentElement,body=document.body;" "const dom=root?root.outerHTML:'';" + "const normalizedRoot=root?root.cloneNode(true):null;" + "if(normalizedRoot){normalizedRoot.querySelectorAll(" + "'style[data-obscura-external-stylesheets],style[data-obscura-linked]'" + ").forEach(node=>node.remove())}" + "const normalizedDom=normalizedRoot?normalizedRoot.outerHTML:'';" "const text=body?body.innerText.replace(/\\s+/g,' ').trim():'';" "const images=Array.from(document.images||[]),fonts=document.fonts;" "const hash=value=>{let h=2166136261;" @@ -138,6 +143,8 @@ def obscura_state_eval_expression(scroll): "document:{ready_state:document.readyState," "element_count:document.getElementsByTagName('*').length," "outer_html_utf16:dom.length,outer_html_fnv1a32:hash(dom)," + "normalized_outer_html_utf16:normalizedDom.length," + "normalized_outer_html_fnv1a32:hash(normalizedDom)," "visible_text_utf16:text.length,visible_text_fnv1a32:hash(text)}," "geometry:{inner_width:innerWidth,inner_height:innerHeight," "scroll_x:scrollX,scroll_y:scrollY," @@ -492,6 +499,13 @@ def capture_chromium_state(page): const root = document.documentElement; const body = document.body; const dom = root ? root.outerHTML : ""; + const normalizedRoot = root ? root.cloneNode(true) : null; + if (normalizedRoot) { + normalizedRoot.querySelectorAll( + "style[data-obscura-external-stylesheets],style[data-obscura-linked]" + ).forEach(node => node.remove()); + } + const normalizedDom = normalizedRoot ? normalizedRoot.outerHTML : ""; const visibleText = body ? body.innerText : ""; const normalizedText = visibleText.replace(/\\s+/g, " ").trim(); const images = Array.from(document.images || []); @@ -528,6 +542,11 @@ def capture_chromium_state(page): outer_html_fnv1a32: fnv1a32(dom), outer_html_bytes: new TextEncoder().encode(dom).length, outer_html_sha256: await sha256(dom), + normalized_outer_html_utf16: normalizedDom.length, + normalized_outer_html_fnv1a32: fnv1a32(normalizedDom), + normalized_outer_html_bytes: + new TextEncoder().encode(normalizedDom).length, + normalized_outer_html_sha256: await sha256(normalizedDom), visible_text_utf16: normalizedText.length, visible_text_fnv1a32: fnv1a32(normalizedText), visible_text_bytes: new TextEncoder().encode(normalizedText).length, @@ -668,6 +687,15 @@ def compare_page_states(obscura, chromium): and obscura_document.get("outer_html_fnv1a32") == chromium_document.get("outer_html_fnv1a32") ), + "normalized_outer_html_utf16_delta": delta( + obscura_document.get("normalized_outer_html_utf16"), + chromium_document.get("normalized_outer_html_utf16"), + ), + "normalized_outer_html_fingerprint_equal": ( + obscura_document.get("normalized_outer_html_fnv1a32") is not None + and obscura_document.get("normalized_outer_html_fnv1a32") + == chromium_document.get("normalized_outer_html_fnv1a32") + ), "visible_text_fingerprint_equal": ( obscura_document.get("visible_text_fnv1a32") is not None and obscura_document.get("visible_text_fnv1a32") @@ -787,7 +815,10 @@ def main(): "DOM/text fingerprints and length deltas expose different live " "page states. They are provenance tripwires, not proof that " "equal states contain equal layout or that unequal serialized " - "DOM necessarily represents a rendering failure." + "DOM necessarily represents a rendering failure. Normalized DOM " + "fingerprints exclude only Obscura's explicitly marked external-" + "stylesheet mirror nodes, because Chromium's CSSOM does not " + "serialize fetched stylesheet text into outerHTML." ), "resource_readiness": ( "Obscura's DOM image/font readiness is sampled before paint; " diff --git a/render-repros/test_paired_corpus.py b/render-repros/test_paired_corpus.py index f01a6fe..7b7114d 100644 --- a/render-repros/test_paired_corpus.py +++ b/render-repros/test_paired_corpus.py @@ -57,6 +57,8 @@ class ControlledScrollTests(unittest.TestCase): def test_every_capture_expression_samples_page_state_without_scrolling(self): expression = paired_corpus.obscura_state_eval_expression(None) self.assertIn("outer_html_fnv1a32", expression) + self.assertIn("data-obscura-external-stylesheets", expression) + self.assertIn("normalized_outer_html_fnv1a32", expression) self.assertIn("visible_text_fnv1a32", expression) self.assertNotIn("window.scrollTo", expression) @@ -80,8 +82,10 @@ class ControlledScrollTests(unittest.TestCase): "ready_state": "complete", "element_count": 9, "outer_html_utf16": 100, + "normalized_outer_html_utf16": 90, "visible_text_utf16": 30, "outer_html_fnv1a32": "aaaaaaaa", + "normalized_outer_html_fnv1a32": "dddddddd", "visible_text_fnv1a32": "bbbbbbbb", }, "geometry": {"document_scroll_height": 1200, "scroll_y": 300}, @@ -91,8 +95,10 @@ class ControlledScrollTests(unittest.TestCase): "ready_state": "complete", "element_count": 7, "outer_html_utf16": 95, + "normalized_outer_html_utf16": 90, "visible_text_utf16": 30, "outer_html_fnv1a32": "cccccccc", + "normalized_outer_html_fnv1a32": "dddddddd", "visible_text_fnv1a32": "bbbbbbbb", }, "geometry": {"document_scroll_height": 1000, "scroll_y": 250}, @@ -101,6 +107,8 @@ class ControlledScrollTests(unittest.TestCase): self.assertTrue(comparison["ready_state_equal"]) self.assertEqual(comparison["element_count_delta"], 2) self.assertFalse(comparison["outer_html_fingerprint_equal"]) + self.assertTrue(comparison["normalized_outer_html_fingerprint_equal"]) + self.assertEqual(comparison["normalized_outer_html_utf16_delta"], 0) self.assertTrue(comparison["visible_text_fingerprint_equal"]) self.assertEqual( comparison["geometry_delta"]["document_scroll_height"], 200