diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 508fd6d..23cefef 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -7398,21 +7398,28 @@ if (typeof Image === 'undefined') { // `.src` flips `complete` and fires `load` on a microtask-later tick. Lazy // loaders and preloaders that create `new Image()`, set `.src`, and wait for // `onload` (or addEventListener('load')) would hang forever otherwise. - Object.defineProperty(img, 'src', { - configurable: true, enumerable: true, - get() { return _imgSrcDesc.get.call(img); }, - set(v) { - _imgSrcDesc.set.call(img, v); - if (!img.getAttribute('src')) return; - img.complete = false; - setTimeout(function () { - img.complete = true; - img.naturalWidth = img.naturalWidth || img.width || 0; - img.naturalHeight = img.naturalHeight || img.height || 0; - try { img.dispatchEvent(new Event('load')); } catch (e) {} - }, 0); - }, - }); + // Anti-bot scripts (Booking.com, issue #394) pre-define a non-configurable + // own `src` on elements; redefining it throws "Cannot redefine + // property: src" and kills the constructor. Skip the load emulation then: + // a page that owns `src` is instrumenting loads itself. + const ownSrc = Object.getOwnPropertyDescriptor(img, 'src'); + if (!ownSrc || ownSrc.configurable) { + Object.defineProperty(img, 'src', { + configurable: true, enumerable: true, + get() { return _imgSrcDesc.get.call(img); }, + set(v) { + _imgSrcDesc.set.call(img, v); + if (!img.getAttribute('src')) return; + img.complete = false; + setTimeout(function () { + img.complete = true; + img.naturalWidth = img.naturalWidth || img.width || 0; + img.naturalHeight = img.naturalHeight || img.height || 0; + try { img.dispatchEvent(new Event('load')); } catch (e) {} + }, 0); + }, + }); + } return img; }; globalThis.Image.prototype = globalThis.HTMLImageElement.prototype; diff --git a/crates/obscura/tests/image_shim.rs b/crates/obscura/tests/image_shim.rs new file mode 100644 index 0000000..5c7b14e --- /dev/null +++ b/crates/obscura/tests/image_shim.rs @@ -0,0 +1,102 @@ +//! Regression test for issue #394 (crash half): `new Image()` must survive a +//! page that pre-defines a non-configurable own `src` on `` elements, the +//! way Booking.com's anti-bot instrumentation does. The Image shim used to +//! unconditionally redefine `src` on the element it just created, throwing +//! `TypeError: Cannot redefine property: src`. + +use std::io::{Read, Write}; + +use obscura::Browser; + +/// Minimal HTTP/1.1 server returning HTML that wraps document.createElement to +/// plant a non-configurable `src` on every , then calls `new Image()`. +fn spawn_server(html: &'static str) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for incoming in listener.incoming() { + let mut s = match incoming { + Ok(s) => s, + Err(_) => continue, + }; + let mut buf = [0u8; 2048]; + let _ = s.read(&mut buf); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ); + let _ = s.write_all(resp.as_bytes()); + let _ = s.shutdown(std::net::Shutdown::Both); + } + }); + format!("http://{}", addr) +} + +#[tokio::test] +async fn new_image_survives_non_configurable_src() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let base = spawn_server( + r#"
waiting
+ +"#, + ); + + let browser = Browser::new().unwrap(); + let mut page = browser.new_page().await.unwrap(); + page.goto(&base).await.unwrap(); + + let text = page.evaluate("document.getElementById('r').textContent"); + assert_eq!( + text.as_str().unwrap_or(""), + "survived w=10 h=20", + "new Image() threw instead of degrading when src is non-configurable" + ); +} + +#[tokio::test] +async fn new_image_still_emulates_load_when_src_is_configurable() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let base = spawn_server( + r#"
waiting
+ +"#, + ); + + let browser = Browser::new().unwrap(); + let mut page = browser.new_page().await.unwrap(); + page.goto(&base).await.unwrap(); + // The shim fires `load` on a setTimeout(0); pump the event loop. + for _ in 0..10 { + page.settle(500).await; + let text = page.evaluate("document.getElementById('r').textContent"); + if text.as_str().unwrap_or("").starts_with("loaded") { + break; + } + } + + let text = page.evaluate("document.getElementById('r').textContent"); + assert_eq!( + text.as_str().unwrap_or(""), + "loaded complete=true", + "load emulation regressed for the normal (configurable src) path" + ); +}