@@ -736,12 +736,169 @@ fn configured_compute_driver(config: &Config) -> Result<ComputeDriverKind> {
736736#[ cfg( test) ]
737737mod tests {
738738 use super :: {
739- ConnectionProtocol , allow_plaintext_service_http, classify_initial_bytes,
740- configured_compute_driver, gateway_listener_addresses, is_benign_tls_handshake_failure,
739+ ConnectionProtocol , MultiplexService , ServerState , TlsAcceptor ,
740+ allow_plaintext_service_http, classify_initial_bytes, configured_compute_driver,
741+ gateway_listener_addresses, is_benign_tls_handshake_failure, serve_gateway_listener,
741742 } ;
742- use openshell_core:: { ComputeDriverKind , Config } ;
743- use std:: io:: { Error , ErrorKind } ;
743+ use openshell_core:: {
744+ ComputeDriverKind , Config ,
745+ proto:: { HealthRequest , open_shell_client:: OpenShellClient } ,
746+ } ;
747+ use rcgen:: { CertificateParams , IsCa , KeyPair } ;
748+ use std:: io:: { Error , ErrorKind , Write } ;
744749 use std:: net:: SocketAddr ;
750+ use std:: sync:: Arc ;
751+ use std:: time:: Duration ;
752+ use tempfile:: { TempDir , tempdir} ;
753+ use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
754+ use tokio:: net:: { TcpListener , TcpStream } ;
755+ use tokio:: sync:: watch;
756+
757+ fn install_rustls_provider ( ) {
758+ let _ = rustls:: crypto:: ring:: default_provider ( ) . install_default ( ) ;
759+ }
760+
761+ fn test_tls_acceptor ( ) -> ( TempDir , TlsAcceptor ) {
762+ install_rustls_provider ( ) ;
763+
764+ let mut ca_params =
765+ CertificateParams :: new ( Vec :: < String > :: new ( ) ) . expect ( "failed to create CA params" ) ;
766+ ca_params. is_ca = IsCa :: Ca ( rcgen:: BasicConstraints :: Unconstrained ) ;
767+ ca_params
768+ . distinguished_name
769+ . push ( rcgen:: DnType :: CommonName , "test-ca" ) ;
770+ let ca_key = KeyPair :: generate ( ) . expect ( "failed to generate CA key" ) ;
771+ let ca_cert = ca_params
772+ . self_signed ( & ca_key)
773+ . expect ( "failed to sign CA cert" ) ;
774+
775+ let server_params = CertificateParams :: new ( vec ! [ "localhost" . to_string( ) ] )
776+ . expect ( "failed to create server params" ) ;
777+ let server_key = KeyPair :: generate ( ) . expect ( "failed to generate server key" ) ;
778+ let server_cert = server_params
779+ . signed_by ( & server_key, & ca_cert, & ca_key)
780+ . expect ( "failed to sign server cert" ) ;
781+
782+ let dir = tempdir ( ) . expect ( "failed to create tempdir" ) ;
783+ let write_file = |name : & str , data : & [ u8 ] | {
784+ let path = dir. path ( ) . join ( name) ;
785+ std:: fs:: File :: create ( & path)
786+ . and_then ( |mut file| file. write_all ( data) )
787+ . expect ( "failed to write tls test file" ) ;
788+ } ;
789+ write_file ( "ca.pem" , ca_cert. pem ( ) . as_bytes ( ) ) ;
790+ write_file ( "server-cert.pem" , server_cert. pem ( ) . as_bytes ( ) ) ;
791+ write_file ( "server-key.pem" , server_key. serialize_pem ( ) . as_bytes ( ) ) ;
792+
793+ let acceptor = TlsAcceptor :: from_files (
794+ & dir. path ( ) . join ( "server-cert.pem" ) ,
795+ & dir. path ( ) . join ( "server-key.pem" ) ,
796+ Some ( & dir. path ( ) . join ( "ca.pem" ) ) ,
797+ false ,
798+ )
799+ . expect ( "failed to build tls acceptor" ) ;
800+
801+ ( dir, acceptor)
802+ }
803+
804+ async fn test_state (
805+ bind_addr : SocketAddr ,
806+ enable_loopback_service_http : bool ,
807+ ) -> Arc < ServerState > {
808+ let store = Arc :: new (
809+ crate :: persistence:: Store :: connect ( "sqlite::memory:?cache=shared" )
810+ . await
811+ . expect ( "failed to create test store" ) ,
812+ ) ;
813+ let compute = crate :: compute:: new_test_runtime ( store. clone ( ) ) . await ;
814+ Arc :: new ( ServerState :: new (
815+ Config :: new ( None )
816+ . with_database_url ( "sqlite::memory:?cache=shared" )
817+ . with_bind_address ( bind_addr)
818+ . with_server_sans ( [ "*.dev.openshell.localhost" ] )
819+ . with_loopback_service_http ( enable_loopback_service_http) ,
820+ store,
821+ compute,
822+ crate :: sandbox_index:: SandboxIndex :: new ( ) ,
823+ crate :: sandbox_watch:: SandboxWatchBus :: new ( ) ,
824+ crate :: tracing_bus:: TracingLogBus :: new ( ) ,
825+ Arc :: new ( crate :: supervisor_session:: SupervisorSessionRegistry :: new ( ) ) ,
826+ None ,
827+ ) )
828+ }
829+
830+ async fn start_tls_gateway_listener (
831+ bind_addr : & str ,
832+ enable_loopback_service_http : bool ,
833+ ) -> (
834+ SocketAddr ,
835+ watch:: Sender < bool > ,
836+ tokio:: task:: JoinHandle < ( ) > ,
837+ TempDir ,
838+ ) {
839+ let listener = TcpListener :: bind ( bind_addr)
840+ . await
841+ . expect ( "failed to bind test listener" ) ;
842+ let listen_addr = listener. local_addr ( ) . expect ( "failed to read local addr" ) ;
843+ let state = test_state ( listen_addr, enable_loopback_service_http) . await ;
844+ let service = MultiplexService :: new ( state) ;
845+ let ( tls_dir, tls_acceptor) = test_tls_acceptor ( ) ;
846+ let ( shutdown_tx, shutdown_rx) = watch:: channel ( false ) ;
847+ let handle = tokio:: spawn ( serve_gateway_listener (
848+ listener,
849+ listen_addr,
850+ service,
851+ Some ( tls_acceptor) ,
852+ enable_loopback_service_http,
853+ shutdown_rx,
854+ ) ) ;
855+ ( listen_addr, shutdown_tx, handle, tls_dir)
856+ }
857+
858+ async fn send_plain_http ( addr : SocketAddr , request : String ) -> String {
859+ let connect_addr: SocketAddr = format ! ( "127.0.0.1:{}" , addr. port( ) )
860+ . parse ( )
861+ . expect ( "failed to build loopback connect addr" ) ;
862+ let mut stream = TcpStream :: connect ( connect_addr)
863+ . await
864+ . expect ( "failed to connect to test listener" ) ;
865+ stream
866+ . write_all ( request. as_bytes ( ) )
867+ . await
868+ . expect ( "failed to write request" ) ;
869+
870+ let mut response = Vec :: new ( ) ;
871+ let read_result =
872+ tokio:: time:: timeout ( Duration :: from_secs ( 2 ) , stream. read_to_end ( & mut response) )
873+ . await
874+ . expect ( "timed out reading response" ) ;
875+ if let Err ( err) = read_result
876+ && err. kind ( ) != ErrorKind :: ConnectionReset
877+ {
878+ panic ! ( "failed to read response: {err}" ) ;
879+ }
880+ String :: from_utf8_lossy ( & response) . into_owned ( )
881+ }
882+
883+ fn service_request ( addr : SocketAddr , extra_headers : & [ ( & str , & str ) ] ) -> String {
884+ let mut request = format ! (
885+ "GET / HTTP/1.1\r \n Host: my-sandbox--web.dev.openshell.localhost:{}\r \n Connection: close\r \n " ,
886+ addr. port( )
887+ ) ;
888+ for ( name, value) in extra_headers {
889+ request. push_str ( name) ;
890+ request. push_str ( ": " ) ;
891+ request. push_str ( value) ;
892+ request. push_str ( "\r \n " ) ;
893+ }
894+ request. push_str ( "\r \n " ) ;
895+ request
896+ }
897+
898+ async fn stop_listener ( shutdown : watch:: Sender < bool > , handle : tokio:: task:: JoinHandle < ( ) > ) {
899+ let _ = shutdown. send ( true ) ;
900+ let _ = tokio:: time:: timeout ( Duration :: from_secs ( 2 ) , handle) . await ;
901+ }
745902
746903 #[ test]
747904 fn classifies_probe_style_tls_disconnects_as_benign ( ) {
@@ -793,6 +950,129 @@ mod tests {
793950 assert ! ( !allow_plaintext_service_http( true , loopback, remote_peer) ) ;
794951 }
795952
953+ #[ tokio:: test]
954+ async fn plaintext_service_http_listener_rejects_non_loopback_bind ( ) {
955+ let ( addr, shutdown, handle, _tls_dir) =
956+ start_tls_gateway_listener ( "0.0.0.0:0" , true ) . await ;
957+
958+ let response = send_plain_http ( addr, service_request ( addr, & [ ] ) ) . await ;
959+
960+ assert ! (
961+ response. is_empty( ) ,
962+ "non-loopback gateway listener should drop plaintext service HTTP, got: {response:?}"
963+ ) ;
964+ stop_listener ( shutdown, handle) . await ;
965+ }
966+
967+ #[ tokio:: test]
968+ async fn plaintext_service_http_rejects_cross_origin_browser_contexts ( ) {
969+ let ( addr, shutdown, handle, _tls_dir) =
970+ start_tls_gateway_listener ( "127.0.0.1:0" , true ) . await ;
971+ let cases = [
972+ (
973+ "cross-site fetch metadata" ,
974+ vec ! [ ( "Sec-Fetch-Site" , "cross-site" ) ] ,
975+ ) ,
976+ (
977+ "same-site sibling fetch metadata" ,
978+ vec ! [ ( "Sec-Fetch-Site" , "same-site" ) ] ,
979+ ) ,
980+ (
981+ "mismatched origin" ,
982+ vec ! [ (
983+ "Origin" ,
984+ "http://other-sandbox--web.dev.openshell.localhost:8080" ,
985+ ) ] ,
986+ ) ,
987+ (
988+ "mismatched referer" ,
989+ vec ! [ (
990+ "Referer" ,
991+ "http://other-sandbox--web.dev.openshell.localhost:8080/page" ,
992+ ) ] ,
993+ ) ,
994+ ] ;
995+
996+ for ( name, headers) in cases {
997+ let response = send_plain_http ( addr, service_request ( addr, & headers) ) . await ;
998+
999+ assert ! (
1000+ response. starts_with( "HTTP/1.1 403 Forbidden" ) ,
1001+ "{name} should be rejected before service lookup, got: {response:?}"
1002+ ) ;
1003+ assert ! (
1004+ response. contains( "Cross-origin service request rejected" ) ,
1005+ "{name} should explain the service rejection, got: {response:?}"
1006+ ) ;
1007+ }
1008+ stop_listener ( shutdown, handle) . await ;
1009+ }
1010+
1011+ #[ tokio:: test]
1012+ async fn plaintext_service_http_allows_same_origin_browser_context_to_reach_service_lookup ( ) {
1013+ let ( addr, shutdown, handle, _tls_dir) =
1014+ start_tls_gateway_listener ( "127.0.0.1:0" , true ) . await ;
1015+ let origin = format ! (
1016+ "http://my-sandbox--web.dev.openshell.localhost:{}" ,
1017+ addr. port( )
1018+ ) ;
1019+ let response = send_plain_http (
1020+ addr,
1021+ service_request (
1022+ addr,
1023+ & [ ( "Sec-Fetch-Site" , "same-origin" ) , ( "Origin" , & origin) ] ,
1024+ ) ,
1025+ )
1026+ . await ;
1027+
1028+ assert ! (
1029+ response. starts_with( "HTTP/1.1 404 Not Found" ) ,
1030+ "same-origin browser context should pass CSRF guard and miss only because no endpoint exists, got: {response:?}"
1031+ ) ;
1032+ assert ! (
1033+ !response. contains( "Cross-origin service request rejected" ) ,
1034+ "same-origin browser context should not be rejected as cross-origin, got: {response:?}"
1035+ ) ;
1036+ stop_listener ( shutdown, handle) . await ;
1037+ }
1038+
1039+ #[ tokio:: test]
1040+ async fn plaintext_service_http_does_not_expose_grpc_gateway ( ) {
1041+ let ( addr, shutdown, handle, _tls_dir) =
1042+ start_tls_gateway_listener ( "127.0.0.1:0" , true ) . await ;
1043+ let grpc_endpoint = format ! ( "http://127.0.0.1:{}" , addr. port( ) ) ;
1044+ let grpc_succeeded = tokio:: time:: timeout ( Duration :: from_secs ( 2 ) , async {
1045+ match OpenShellClient :: connect ( grpc_endpoint) . await {
1046+ Ok ( mut client) => client. health ( HealthRequest { } ) . await . is_ok ( ) ,
1047+ Err ( _) => false ,
1048+ }
1049+ } )
1050+ . await
1051+ . expect ( "timed out checking plaintext gRPC exposure" ) ;
1052+
1053+ assert ! (
1054+ !grpc_succeeded,
1055+ "plaintext service HTTP must not expose successful gateway gRPC"
1056+ ) ;
1057+
1058+ let request = format ! (
1059+ "POST /openshell.v1.OpenShell/Health HTTP/1.1\r \n Host: 127.0.0.1:{}\r \n Content-Type: application/grpc\r \n TE: trailers\r \n Content-Length: 0\r \n Connection: close\r \n \r \n " ,
1060+ addr. port( )
1061+ ) ;
1062+
1063+ let response = send_plain_http ( addr, request) . await ;
1064+
1065+ assert ! (
1066+ response. starts_with( "HTTP/1.1 404 Not Found" ) ,
1067+ "plaintext service HTTP router should not serve gateway gRPC, got: {response:?}"
1068+ ) ;
1069+ assert ! (
1070+ !response. contains( "grpc-status: 0" ) ,
1071+ "plaintext service HTTP must not return a successful gRPC response: {response:?}"
1072+ ) ;
1073+ stop_listener ( shutdown, handle) . await ;
1074+ }
1075+
7961076 #[ test]
7971077 fn configured_compute_driver_triggers_auto_detection_when_empty ( ) {
7981078 let config = Config :: new ( None ) . with_compute_drivers ( [ ] ) ;
0 commit comments