Skip to content

Commit e93acc6

Browse files
ctateMuhtasham
authored andcommitted
Require same-origin stream commands (vercel-labs#1355)
* Require same-origin stream commands Protect the per-session command relay from browser-originated cross-origin requests while preserving same-origin dashboard access. Co-authored-by: Muhtasham <20128202+Muhtasham@users.noreply.github.com> * Harden stream command origin checks Require command relay requests to come from loopback same-origin metadata and prevent request bodies from spoofing security headers. Co-authored-by: Muhtasham <20128202+Muhtasham@users.noreply.github.com> --------- Co-authored-by: Muhtasham <20128202+Muhtasham@users.noreply.github.com>
1 parent d2a33cc commit e93acc6

2 files changed

Lines changed: 553 additions & 6 deletions

File tree

cli/src/native/e2e_tests.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,61 @@ async fn create_storage_state_with_cookie(path: &str, cookie_name: &str, cookie_
9494
assert_success(&resp);
9595
}
9696

97+
async fn send_raw_http_request(port: u64, request: &str) -> String {
98+
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
99+
.await
100+
.expect("HTTP client should connect to stream server");
101+
stream
102+
.write_all(request.as_bytes())
103+
.await
104+
.expect("HTTP request should be written");
105+
stream
106+
.shutdown()
107+
.await
108+
.expect("HTTP client write side should shut down");
109+
110+
let mut response = Vec::new();
111+
stream
112+
.read_to_end(&mut response)
113+
.await
114+
.expect("HTTP response should be read");
115+
String::from_utf8(response).expect("HTTP response should be utf-8")
116+
}
117+
118+
#[cfg(unix)]
119+
async fn spawn_fake_daemon_socket(
120+
socket_dir: &std::path::Path,
121+
session_name: &str,
122+
) -> tokio::sync::oneshot::Receiver<String> {
123+
use tokio::io::AsyncBufReadExt;
124+
125+
let socket_path = socket_dir.join(format!("{session_name}.sock"));
126+
let _ = std::fs::remove_file(&socket_path);
127+
let listener =
128+
tokio::net::UnixListener::bind(&socket_path).expect("fake daemon socket should bind");
129+
let (tx, rx) = tokio::sync::oneshot::channel();
130+
131+
tokio::spawn(async move {
132+
let Ok((stream, _)) = listener.accept().await else {
133+
return;
134+
};
135+
let mut reader = tokio::io::BufReader::new(stream);
136+
let mut command = String::new();
137+
if reader.read_line(&mut command).await.is_err() {
138+
return;
139+
}
140+
141+
let mut stream = reader.into_inner();
142+
let _ = stream
143+
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
144+
.await;
145+
let _ = stream.write_all(b"\n").await;
146+
let _ = tx.send(command);
147+
});
148+
149+
rx
150+
}
151+
97152
// ---------------------------------------------------------------------------
98153
// Core: launch, navigate, evaluate, url, title, close
99154
// ---------------------------------------------------------------------------
@@ -363,6 +418,98 @@ async fn e2e_runtime_stream_enable_before_launch_attaches_and_disables() {
363418
let _ = std::fs::remove_dir_all(&socket_dir);
364419
}
365420

421+
#[cfg(unix)]
422+
#[tokio::test]
423+
#[ignore]
424+
async fn e2e_stream_command_requires_same_origin_before_daemon_relay() {
425+
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
426+
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
427+
.join("target")
428+
.join("t");
429+
std::fs::create_dir_all(&temp_parent).expect("socket temp parent should be created");
430+
let socket_dir = tempfile::Builder::new()
431+
.prefix("ab-e2e-")
432+
.tempdir_in(temp_parent)
433+
.expect("socket dir should be created");
434+
guard.set(
435+
"AGENT_BROWSER_SOCKET_DIR",
436+
socket_dir
437+
.path()
438+
.to_str()
439+
.expect("socket dir should be utf-8"),
440+
);
441+
guard.set("AGENT_BROWSER_SESSION", "x");
442+
443+
let mut state = DaemonState::new();
444+
let resp = execute_command(
445+
&json!({ "id": "1", "action": "stream_enable", "port": 0 }),
446+
&mut state,
447+
)
448+
.await;
449+
assert_success(&resp);
450+
let port = get_data(&resp)["port"]
451+
.as_u64()
452+
.expect("stream enable should report the bound port");
453+
454+
let mut daemon_command = spawn_fake_daemon_socket(socket_dir.path(), "x").await;
455+
let body = r#"{"action":"tabs"}"#;
456+
let cross_origin_request = format!(
457+
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
458+
body.len(),
459+
body
460+
);
461+
462+
let response = send_raw_http_request(port, &cross_origin_request).await;
463+
assert!(
464+
response.starts_with("HTTP/1.1 403 Forbidden"),
465+
"unexpected cross-origin response: {response}"
466+
);
467+
assert!(
468+
!response.contains("Access-Control-Allow-Origin: *"),
469+
"forbidden command response exposed wildcard CORS: {response}"
470+
);
471+
assert!(
472+
tokio::time::timeout(std::time::Duration::from_millis(100), &mut daemon_command)
473+
.await
474+
.is_err(),
475+
"cross-origin command request reached daemon relay"
476+
);
477+
478+
let same_origin_request = format!(
479+
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
480+
body.len(),
481+
body
482+
);
483+
let response = send_raw_http_request(port, &same_origin_request).await;
484+
assert!(
485+
response.starts_with("HTTP/1.1 200 OK"),
486+
"unexpected same-origin response: {response}"
487+
);
488+
assert!(
489+
response.contains(&format!(
490+
"Access-Control-Allow-Origin: http://localhost:{port}"
491+
)),
492+
"same-origin command response did not reflect origin: {response}"
493+
);
494+
assert!(
495+
!response.contains("Access-Control-Allow-Origin: *"),
496+
"same-origin command response exposed wildcard CORS: {response}"
497+
);
498+
499+
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
500+
.await
501+
.expect("same-origin request should reach fake daemon")
502+
.expect("fake daemon should return relayed command");
503+
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
504+
505+
let resp = execute_command(
506+
&json!({ "id": "2", "action": "stream_disable" }),
507+
&mut state,
508+
)
509+
.await;
510+
assert_success(&resp);
511+
}
512+
366513
// ---------------------------------------------------------------------------
367514
// Snapshot with refs and ref-based click
368515
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)