From 7e2cabf4111365603024734af5646a1b1897811f Mon Sep 17 00:00:00 2001 From: sicko Date: Tue, 14 Jul 2026 09:21:15 +1000 Subject: [PATCH] fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit() (#395) * fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit() Per the HTML standard, the submit() method submits without firing a submit event and cannot be canceled by a page submit listener; only requestSubmit() and user-initiated submits fire the cancelable event. bootstrap.js dispatched a cancelable submit event from submit() and aborted navigation when canceled, which breaks the common pattern where a submit listener preventDefault()s the native submit and a callback later calls form.submit() to actually send it (e.g. an invisible-reCAPTCHA data-callback): the listener also canceled the programmatic submit, so no navigation was ever issued. - submit(): navigate unconditionally, no submit event - requestSubmit(): fire the cancelable submit event, navigate only if not canceled - submit-button clicks route through requestSubmit() (a real click fires the event) Adds a regression test; full obscura-cdp suite (56 tests) passes. * fix(cdp): route synthetic submit-button clicks and Enter through requestSubmit A CDP Input.dispatchMouseEvent on a submit button and an Enter key in a form field are user-initiated submits, so per the HTML spec they must fire the cancelable submit event. input.rs called form.submit() (the method) directly, bypassing any preventDefault() submit listener, which mirrors the bootstrap.js bug fixed in the parent commit. Use requestSubmit() with a submit() fallback so the listener can veto. --------- Co-authored-by: SGavrl <277843151+SGavrl@users.noreply.github.com> --- crates/obscura-cdp/src/domains/input.rs | 6 +- .../form_submit_method_bypasses_listener.rs | 184 ++++++++++++++++++ crates/obscura-js/js/bootstrap.js | 19 +- 3 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs 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++) {