Skip to content

Commit 20213d2

Browse files
committed
test(server): cover service endpoint plaintext security
1 parent 0797fef commit 20213d2

1 file changed

Lines changed: 284 additions & 4 deletions

File tree

  • crates/openshell-server/src

crates/openshell-server/src/lib.rs

Lines changed: 284 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -725,12 +725,169 @@ fn configured_compute_driver(config: &Config) -> Result<ComputeDriverKind> {
725725
#[cfg(test)]
726726
mod 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\nHost: my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: 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\nHost: 127.0.0.1:{}\r\nContent-Type: application/grpc\r\nTE: trailers\r\nContent-Length: 0\r\nConnection: 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

Comments
 (0)