diff --git a/crates/obscura-cdp/src/domains/input.rs b/crates/obscura-cdp/src/domains/input.rs index 7309633..e394f49 100644 --- a/crates/obscura-cdp/src/domains/input.rs +++ b/crates/obscura-cdp/src/domains/input.rs @@ -151,8 +151,12 @@ pub async fn handle( var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:'{key}',code:'{code}'}}));\ target.dispatchEvent(evt);\ }})()", - key = key.replace('\'', "\\'"), - code = code.replace('\'', "\\'"), + // Escape backslash BEFORE single-quote (as the text + // path below does) so a key like "\" — Chrome's + // backslash key — doesn't escape the closing quote + // and produce a syntax error that drops the event. + key = key.replace('\\', "\\\\").replace('\'', "\\'"), + code = code.replace('\\', "\\\\").replace('\'', "\\'"), ); page.evaluate(&js); @@ -194,8 +198,8 @@ pub async fn handle( var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keyup', {{bubbles:true,key:'{key}',code:'{code}'}}));\ target.dispatchEvent(evt);\ }})()", - key = key.replace('\'', "\\'"), - code = code.replace('\'', "\\'"), + key = key.replace('\\', "\\\\").replace('\'', "\\'"), + code = code.replace('\\', "\\\\").replace('\'', "\\'"), ); page.evaluate(&js); } diff --git a/crates/obscura-cdp/tests/input_key_event_escaping.rs b/crates/obscura-cdp/tests/input_key_event_escaping.rs new file mode 100644 index 0000000..ba0cfeb --- /dev/null +++ b/crates/obscura-cdp/tests/input_key_event_escaping.rs @@ -0,0 +1,104 @@ +//! `Input.dispatchKeyEvent` interpolates the `key`/`code` params into a +//! generated `KeyboardEvent(...)` snippet. They must be escaped for BOTH +//! backslash and single-quote (issue #433): Chrome sends `key: "\\"` (U+005C) +//! when the backslash key is pressed, and quote-only escaping turns that into +//! `key:'\'` — the backslash escapes the closing quote, the literal runs on, +//! and the whole `page.evaluate` is a syntax error, so the `keydown` is +//! silently never dispatched. Regression test: the backslash key must arrive. + +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 page that records the `key` of the last keydown event on the body. +async fn serve_page() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await.unwrap(); + let body = r#"
+ + +"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(resp.as_bytes()).await; + }); + }); + 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!({})) +} + +#[tokio::test(flavor = "current_thread")] +async fn dispatch_key_event_escapes_backslash_in_key_and_code() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let url = serve_page().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()); + + cdp(&mut ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await; + + // The backslash key: Chrome sends key="\" (a single backslash) code="Backslash". + cdp( + &mut ctx, + 2, + "Input.dispatchKeyEvent", + json!({"type": "keyDown", "key": "\\", "code": "Backslash"}), + session_id, + ) + .await; + + // A key whose name itself contains a quote AND a backslash, to exercise the + // ordering of the two replacements. + cdp( + &mut ctx, + 3, + "Input.dispatchKeyEvent", + json!({"type": "keyDown", "key": "a", "code": "KeyA"}), + session_id, + ) + .await; + + let v = cdp( + &mut ctx, + 4, + "Runtime.evaluate", + json!({"expression": "JSON.stringify(window.__keys)", "returnByValue": true}), + session_id, + ) + .await; + + let keys: Vec