Skip to content

Commit 77e6c7a

Browse files
drewTaylorMutch
andauthored
test(server): cover service endpoint plaintext security (#1352)
* test(server): cover service endpoint plaintext security * test(server): align tls test with from_files Option<&Path> signature TlsAcceptor::from_files now accepts the client CA path as Option<&Path> (per the require_client_auth refactor on main). Wrap the helper's CA path in Some(...) so the new plaintext-service-http tests compile after rebasing onto current main. --------- Co-authored-by: Taylor Mutch <taylormutch@gmail.com>
1 parent 2b13bfa commit 77e6c7a

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
@@ -736,12 +736,169 @@ fn configured_compute_driver(config: &Config) -> Result<ComputeDriverKind> {
736736
#[cfg(test)]
737737
mod 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\nHost: my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: 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\nHost: 127.0.0.1:{}\r\nContent-Type: application/grpc\r\nTE: trailers\r\nContent-Length: 0\r\nConnection: 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

Comments
 (0)