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 <[email protected]>
This commit is contained in:
sickoandSGavrl authored and GitHub committed 2026-07-13 23:21:15 +00:00
1 parent 87fefef1ec
commit 7e2cabf411
3 files changed
+204 -5

No files matched your search

@@ -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", "<html><body>submitted</body></html>")
} else {
(
"200 OK",
r#"<html><body>
<form id="f" action="/submitted">
<input type="hidden" name="q" value="1">
<button id="b" type="submit">Go</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', function(e) { e.preventDefault(); });
</script>
</body></html>"#,
)
};
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"
);
}