Reject unsuccessful script responses
This commit is contained in:
1 parent
caaf02e11c
commit
f61493fbd1
5 files changed
+132
-4
No files matched your search
@@ -839,7 +839,7 @@ impl Page {
|
|||||||
if let Some(js) = &mut self.js {
|
if let Some(js) = &mut self.js {
|
||||||
let escaped_css = all_css.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$");
|
let escaped_css = all_css.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$");
|
||||||
let code = format!(
|
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
|
escaped_css
|
||||||
);
|
);
|
||||||
let _ = js.execute_script("<fetch_stylesheets>", &code);
|
let _ = js.execute_script("<fetch_stylesheets>", &code);
|
||||||
@@ -1084,6 +1084,23 @@ impl Page {
|
|||||||
let mut fetched: std::collections::HashMap<usize, (String, String, obscura_net::Response)> = std::collections::HashMap::new();
|
let mut fetched: std::collections::HashMap<usize, (String, String, obscura_net::Response)> = std::collections::HashMap::new();
|
||||||
for result in fetch_results {
|
for result in fetch_results {
|
||||||
if let Some((idx, url, resp)) = result {
|
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
|
// Script bodies: only the HTTP Content-Type charset matters
|
||||||
// (no in-band meta-charset for JS).
|
// (no in-band meta-charset for JS).
|
||||||
let code = obscura_net::decode_non_html(&resp.body, resp.content_type());
|
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 {
|
fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
|
||||||
if pattern == "*" {
|
if pattern == "*" {
|
||||||
return true;
|
return true;
|
||||||
@@ -2299,10 +2320,23 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
parse_import_url, rebase_css_urls, should_restore_hydration_snapshot, split_css_imports,
|
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;
|
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")]
|
#[cfg(feature = "render")]
|
||||||
#[test]
|
#[test]
|
||||||
fn page_screenshot_uses_the_live_window_scroll_offset() {
|
fn page_screenshot_uses_the_live_window_scroll_offset() {
|
||||||
|
|||||||
Vendored
+10
-1
@@ -253,7 +253,16 @@ async function __processDynScriptQueue() {
|
|||||||
body = _decodeDataScriptUrl(task.url);
|
body = _decodeDataScriptUrl(task.url);
|
||||||
} else {
|
} else {
|
||||||
const raw = await Deno.core.ops.op_fetch_url(task.url, "GET", "{}", "", task.pageOrigin, "no-cors");
|
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) {
|
if (body) {
|
||||||
globalThis.__currentScriptNid = task.nid;
|
globalThis.__currentScriptNid = task.nid;
|
||||||
|
|||||||
@@ -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("<html><head></head><body></body></html>");
|
||||||
|
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")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn test_response_array_buffer_preserves_typed_array_view() {
|
async fn test_response_array_buffer_preserves_typed_array_view() {
|
||||||
let mut rt = setup_runtime("<html><body></body></html>");
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ def obscura_state_eval_expression(scroll):
|
|||||||
+ scroll_script
|
+ scroll_script
|
||||||
+ "const root=document.documentElement,body=document.body;"
|
+ "const root=document.documentElement,body=document.body;"
|
||||||
"const dom=root?root.outerHTML:'';"
|
"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 text=body?body.innerText.replace(/\\s+/g,' ').trim():'';"
|
||||||
"const images=Array.from(document.images||[]),fonts=document.fonts;"
|
"const images=Array.from(document.images||[]),fonts=document.fonts;"
|
||||||
"const hash=value=>{let h=2166136261;"
|
"const hash=value=>{let h=2166136261;"
|
||||||
@@ -138,6 +143,8 @@ def obscura_state_eval_expression(scroll):
|
|||||||
"document:{ready_state:document.readyState,"
|
"document:{ready_state:document.readyState,"
|
||||||
"element_count:document.getElementsByTagName('*').length,"
|
"element_count:document.getElementsByTagName('*').length,"
|
||||||
"outer_html_utf16:dom.length,outer_html_fnv1a32:hash(dom),"
|
"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)},"
|
"visible_text_utf16:text.length,visible_text_fnv1a32:hash(text)},"
|
||||||
"geometry:{inner_width:innerWidth,inner_height:innerHeight,"
|
"geometry:{inner_width:innerWidth,inner_height:innerHeight,"
|
||||||
"scroll_x:scrollX,scroll_y:scrollY,"
|
"scroll_x:scrollX,scroll_y:scrollY,"
|
||||||
@@ -492,6 +499,13 @@ def capture_chromium_state(page):
|
|||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const dom = root ? root.outerHTML : "";
|
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 visibleText = body ? body.innerText : "";
|
||||||
const normalizedText = visibleText.replace(/\\s+/g, " ").trim();
|
const normalizedText = visibleText.replace(/\\s+/g, " ").trim();
|
||||||
const images = Array.from(document.images || []);
|
const images = Array.from(document.images || []);
|
||||||
@@ -528,6 +542,11 @@ def capture_chromium_state(page):
|
|||||||
outer_html_fnv1a32: fnv1a32(dom),
|
outer_html_fnv1a32: fnv1a32(dom),
|
||||||
outer_html_bytes: new TextEncoder().encode(dom).length,
|
outer_html_bytes: new TextEncoder().encode(dom).length,
|
||||||
outer_html_sha256: await sha256(dom),
|
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_utf16: normalizedText.length,
|
||||||
visible_text_fnv1a32: fnv1a32(normalizedText),
|
visible_text_fnv1a32: fnv1a32(normalizedText),
|
||||||
visible_text_bytes: new TextEncoder().encode(normalizedText).length,
|
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")
|
and obscura_document.get("outer_html_fnv1a32")
|
||||||
== chromium_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": (
|
"visible_text_fingerprint_equal": (
|
||||||
obscura_document.get("visible_text_fnv1a32") is not None
|
obscura_document.get("visible_text_fnv1a32") is not None
|
||||||
and obscura_document.get("visible_text_fnv1a32")
|
and obscura_document.get("visible_text_fnv1a32")
|
||||||
@@ -787,7 +815,10 @@ def main():
|
|||||||
"DOM/text fingerprints and length deltas expose different live "
|
"DOM/text fingerprints and length deltas expose different live "
|
||||||
"page states. They are provenance tripwires, not proof that "
|
"page states. They are provenance tripwires, not proof that "
|
||||||
"equal states contain equal layout or that unequal serialized "
|
"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": (
|
"resource_readiness": (
|
||||||
"Obscura's DOM image/font readiness is sampled before paint; "
|
"Obscura's DOM image/font readiness is sampled before paint; "
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class ControlledScrollTests(unittest.TestCase):
|
|||||||
def test_every_capture_expression_samples_page_state_without_scrolling(self):
|
def test_every_capture_expression_samples_page_state_without_scrolling(self):
|
||||||
expression = paired_corpus.obscura_state_eval_expression(None)
|
expression = paired_corpus.obscura_state_eval_expression(None)
|
||||||
self.assertIn("outer_html_fnv1a32", expression)
|
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.assertIn("visible_text_fnv1a32", expression)
|
||||||
self.assertNotIn("window.scrollTo", expression)
|
self.assertNotIn("window.scrollTo", expression)
|
||||||
|
|
||||||
@@ -80,8 +82,10 @@ class ControlledScrollTests(unittest.TestCase):
|
|||||||
"ready_state": "complete",
|
"ready_state": "complete",
|
||||||
"element_count": 9,
|
"element_count": 9,
|
||||||
"outer_html_utf16": 100,
|
"outer_html_utf16": 100,
|
||||||
|
"normalized_outer_html_utf16": 90,
|
||||||
"visible_text_utf16": 30,
|
"visible_text_utf16": 30,
|
||||||
"outer_html_fnv1a32": "aaaaaaaa",
|
"outer_html_fnv1a32": "aaaaaaaa",
|
||||||
|
"normalized_outer_html_fnv1a32": "dddddddd",
|
||||||
"visible_text_fnv1a32": "bbbbbbbb",
|
"visible_text_fnv1a32": "bbbbbbbb",
|
||||||
},
|
},
|
||||||
"geometry": {"document_scroll_height": 1200, "scroll_y": 300},
|
"geometry": {"document_scroll_height": 1200, "scroll_y": 300},
|
||||||
@@ -91,8 +95,10 @@ class ControlledScrollTests(unittest.TestCase):
|
|||||||
"ready_state": "complete",
|
"ready_state": "complete",
|
||||||
"element_count": 7,
|
"element_count": 7,
|
||||||
"outer_html_utf16": 95,
|
"outer_html_utf16": 95,
|
||||||
|
"normalized_outer_html_utf16": 90,
|
||||||
"visible_text_utf16": 30,
|
"visible_text_utf16": 30,
|
||||||
"outer_html_fnv1a32": "cccccccc",
|
"outer_html_fnv1a32": "cccccccc",
|
||||||
|
"normalized_outer_html_fnv1a32": "dddddddd",
|
||||||
"visible_text_fnv1a32": "bbbbbbbb",
|
"visible_text_fnv1a32": "bbbbbbbb",
|
||||||
},
|
},
|
||||||
"geometry": {"document_scroll_height": 1000, "scroll_y": 250},
|
"geometry": {"document_scroll_height": 1000, "scroll_y": 250},
|
||||||
@@ -101,6 +107,8 @@ class ControlledScrollTests(unittest.TestCase):
|
|||||||
self.assertTrue(comparison["ready_state_equal"])
|
self.assertTrue(comparison["ready_state_equal"])
|
||||||
self.assertEqual(comparison["element_count_delta"], 2)
|
self.assertEqual(comparison["element_count_delta"], 2)
|
||||||
self.assertFalse(comparison["outer_html_fingerprint_equal"])
|
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.assertTrue(comparison["visible_text_fingerprint_equal"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
comparison["geometry_delta"]["document_scroll_height"], 200
|
comparison["geometry_delta"]["document_scroll_height"], 200
|
||||||
|
|||||||
Reference in new issue
Block a user