@@ -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 \n Host: localhost:{port}\r \n Origin: https://evil.example\r \n Content-Type: application/json\r \n Content-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 \n Host: localhost:{port}\r \n Origin: http://localhost:{port}\r \n Content-Type: application/json\r \n Content-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