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