diff --git a/crates/obscura-cdp/src/domains/input.rs b/crates/obscura-cdp/src/domains/input.rs index 4b001aa..7309633 100644 --- a/crates/obscura-cdp/src/domains/input.rs +++ b/crates/obscura-cdp/src/domains/input.rs @@ -93,10 +93,10 @@ pub async fn handle( var type = (target.getAttribute && target.getAttribute('type') || '').toLowerCase();\ if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\ var form = target.closest ? target.closest('form') : null;\ - if (form && typeof form.submit === 'function') {{ try {{ form.submit(target); }} catch(e) {{}} }}\ + if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(target); }} else {{ form.submit(target); }} }} catch(e) {{}} }}\ }} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\ var form2 = target.closest ? target.closest('form') : null;\ - if (form2 && typeof form2.submit === 'function') {{ try {{ form2.submit(target); }} catch(e) {{}} }}\ + if (form2) {{ try {{ if (typeof form2.requestSubmit === 'function') {{ form2.requestSubmit(target); }} else {{ form2.submit(target); }} }} catch(e) {{}} }}\ }} else if (tag === 'INPUT' && (type === 'checkbox' || type === 'radio')) {{\ target.checked = !target.checked;\ try {{ target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); }} catch(e) {{}}\ @@ -177,7 +177,7 @@ pub async fn handle( target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\ } else {\ var form = target.form || (target.closest && target.closest('form'));\ - if (form && typeof form.submit === 'function') form.submit();\ + if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(); }} else {{ form.submit(); }} }} catch(e) {{}} }}\ }\ })()"; page.evaluate(js); diff --git a/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs b/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs new file mode 100644 index 0000000..778e525 --- /dev/null +++ b/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs @@ -0,0 +1,184 @@ +//! `HTMLFormElement.submit()` (the method) must submit WITHOUT firing a +//! cancelable `submit` event, so a page's `submit` listener that calls +//! `preventDefault()` cannot veto it. Only `requestSubmit()` and user-initiated +//! submits fire the cancelable event. Regression test for the invisible-reCAPTCHA +//! login pattern (listener preventDefaults; a success callback calls +//! `form.submit()` to actually send the form). + +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; + +// Serves a form whose `submit` listener always calls preventDefault(). +async fn serve_form() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + for _ in 0..2 { + let (mut socket, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let n = socket.read(&mut buf).await.unwrap(); + let req = String::from_utf8_lossy(&buf[..n]); + let (status, body) = if req.starts_with("GET /submitted") { + ("200 OK", "
submitted") + } else { + ( + "200 OK", + r#" + + +"#, + ) + }; + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + }); + } + }); + 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 navigate(ctx: &mut CdpContext, url: &str, session_id: &str) { + cdp(ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn submit_method_navigates_despite_prevent_default_listener() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let url = serve_form().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()); + + navigate(&mut ctx, &url, session_id).await; + + // The submit() METHOD must not fire the cancelable submit event, so the + // page's preventDefault() listener cannot stop it: navigation proceeds. + cdp( + &mut ctx, + 2, + "Runtime.evaluate", + json!({"expression": "document.getElementById('f').submit()"}), + session_id, + ) + .await; + + let page = ctx.get_page_mut(&page_id).unwrap(); + assert_eq!( + page.url.as_ref().unwrap().path(), + "/submitted", + "form.submit() should navigate even though a submit listener preventDefault()s" + ); + assert_eq!(page.url.as_ref().unwrap().query(), Some("q=1")); +} + +#[tokio::test(flavor = "current_thread")] +async fn request_submit_is_vetoed_by_prevent_default_listener() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let url = serve_form().await; + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = "session-2"; + ctx.sessions.insert(session_id.to_string(), page_id.clone()); + + navigate(&mut ctx, &url, session_id).await; + + // requestSubmit() DOES fire the cancelable submit event, so the listener's + // preventDefault() cancels it: the page must NOT navigate. + let has = cdp( + &mut ctx, + 2, + "Runtime.evaluate", + json!({"expression": "typeof document.getElementById('f').requestSubmit", "returnByValue": true}), + session_id, + ) + .await; + assert_eq!(has["result"]["value"], "function", "requestSubmit must exist"); + + cdp( + &mut ctx, + 3, + "Runtime.evaluate", + json!({"expression": "document.getElementById('f').requestSubmit()"}), + session_id, + ) + .await; + + let page = ctx.get_page_mut(&page_id).unwrap(); + assert_ne!( + page.url.as_ref().unwrap().path(), + "/submitted", + "requestSubmit() must be cancelable by a preventDefault() submit listener" + ); +} + +// A synthetic CDP click on a submit button is a user-initiated submit, so the +// cancelable `submit` event must fire and a preventDefault() listener must be +// able to veto navigation. Before the input.rs fix this path called +// `form.submit()` directly, bypassing the listener. Regression test for the +// CDP automation surface (Puppeteer/Playwright elementHandle.click()). +#[tokio::test(flavor = "current_thread")] +async fn cdp_click_submit_button_is_vetoed_by_prevent_default_listener() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let url = serve_form().await; + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = "session-3"; + ctx.sessions.insert(session_id.to_string(), page_id.clone()); + + navigate(&mut ctx, &url, session_id).await; + + // Point the CDP click resolver at the submit button explicitly so the test + // does not depend on layout coordinates. + cdp( + &mut ctx, + 2, + "Runtime.evaluate", + json!({"expression": "globalThis.__obscura_click_target = document.getElementById('b')"}), + session_id, + ) + .await; + + cdp( + &mut ctx, + 3, + "Input.dispatchMouseEvent", + json!({"type": "mousePressed", "x": 0.0, "y": 0.0, "button": "left", "clickCount": 1}), + session_id, + ) + .await; + + let page = ctx.get_page_mut(&page_id).unwrap(); + assert_ne!( + page.url.as_ref().unwrap().path(), + "/submitted", + "a CDP click on a submit button must fire the cancelable submit event and be vetoable" + ); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 41c482a..015adad 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1456,7 +1456,11 @@ class Element extends Node { const type = (this.getAttribute('type') || '').toLowerCase(); if (type === 'submit' || (this.localName === 'button' && type !== 'button' && type !== 'reset')) { const form = this.closest ? this.closest('form') : null; - if (form && typeof form.submit === 'function') { + // A real submit-button click fires the cancelable submit event, so use + // requestSubmit() (not the plain submit() method, which now bypasses it). + if (form && typeof form.requestSubmit === 'function') { + form.requestSubmit(this); + } else if (form && typeof form.submit === 'function') { form.submit(this); } } @@ -1913,10 +1917,21 @@ class Element extends Node { opts[i]._selected = (i === v); } } + // Per the HTML spec, the submit() METHOD submits the form WITHOUT firing a + // cancelable `submit` event — a page's submit listener cannot veto it. Only + // requestSubmit() and user-initiated submits fire the cancelable event. + // Conflating the two broke sites whose submit listener preventDefault()s the + // native submit and then calls form.submit() from a callback (e.g. an + // invisible-reCAPTCHA data-callback) to actually send the form. submit(submitter) { + this._navigateSubmit(submitter); + } + requestSubmit(submitter) { const cancelled = !this.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); if (cancelled) return; - + this._navigateSubmit(submitter); + } + _navigateSubmit(submitter) { const pairs = []; const fields = this.querySelectorAll('input, select, textarea'); for (let i = 0; i < fields.length; i++) {