firegex/backend/proxysrc/tests/tls.rs
2026-09-18 09:34:44 +02:00

215 lines
7.5 KiB
Rust

//! TLS terminated by the engine, and the reason that is worth doing.
//!
//! The TLS module gets here with nginx: terminate on one loopback port, forward
//! plaintext to a second, re-encrypt, forward on — and attach a filter engine to the
//! leg in between, which is the one hop where the traffic is in the clear. Doing it
//! in the engine means the plaintext exists only inside this process, and the rules
//! that need to see it are already there.
use std::sync::Arc;
use std::time::Duration;
use fgex_proxy::filter::{ChainHandle, FilterChain};
use fgex_proxy::proxy::{Proxy, ProxyConfig, TlsSetup};
use fgex_proxy::spec::parse_filters;
use fgex_proxy::tls;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
/// Generated per run: no key material belongs in the repository.
fn self_signed() -> (String, String) {
let cert =
rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.unwrap();
(cert.cert.pem(), cert.key_pair.serialize_pem())
}
/// Plain echo, standing in for a service that does not speak TLS itself.
async fn spawn_plain_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
continue;
};
tokio::spawn(async move {
let mut buf = vec![0u8; 16 * 1024];
loop {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if sock.write_all(&buf[..n]).await.is_err() {
break;
}
}
}
}
});
}
});
addr
}
/// An echo that speaks TLS, standing in for the CTF service that natively does.
async fn spawn_tls_echo(cert: &str, key: &str) -> std::net::SocketAddr {
let config = tls::server_config(cert, key).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let acceptor = tls::acceptor(config);
loop {
let Ok((sock, _)) = listener.accept().await else {
continue;
};
let acceptor = acceptor.clone();
tokio::spawn(async move {
let Ok(mut stream) = acceptor.accept(sock).await else {
return;
};
let mut buf = vec![0u8; 16 * 1024];
loop {
match stream.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if stream.write_all(&buf[..n]).await.is_err() {
break;
}
}
}
}
});
}
});
addr
}
async fn spawn_proxy(
upstream: std::net::SocketAddr,
filters: &str,
tls_setup: TlsSetup,
) -> std::net::SocketAddr {
let chain = ChainHandle::new(FilterChain::new(
parse_filters(filters).unwrap(),
Duration::from_millis(500),
));
let mut cfg = ProxyConfig::fixed("127.0.0.1:0".parse().unwrap(), upstream);
cfg.tls = tls_setup;
let proxy = Proxy::bind(cfg, chain).await.unwrap();
let addr = proxy.local_addr().unwrap();
tokio::spawn(proxy.serve());
addr
}
/// A client that speaks TLS to the proxy, accepting whatever it presents.
async fn tls_roundtrip(addr: std::net::SocketAddr, payload: &[u8]) -> std::io::Result<Vec<u8>> {
let connector = tls::connector(tls::client_config().unwrap());
let sock = TcpStream::connect(addr).await?;
let name = tls::server_name("localhost").unwrap();
let mut stream = connector.connect(name, sock).await?;
stream.write_all(payload).await?;
let mut buf = vec![0u8; 64 * 1024];
let n = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf)).await??;
buf.truncate(n);
Ok(buf)
}
#[tokio::test]
async fn terminates_tls_and_relays_to_a_plain_service() {
let (cert, key) = self_signed();
let upstream = spawn_plain_echo().await;
let addr = spawn_proxy(
upstream,
"",
TlsSetup {
server: Some(tls::server_config(&cert, &key).unwrap()),
upstream: None,
optional: false,
},
)
.await;
let got = tls_roundtrip(addr, b"hello over tls").await.unwrap();
assert_eq!(got, b"hello over tls");
}
/// The shape the TLS module is for: the service speaks TLS too, so the engine
/// decrypts, inspects, and re-encrypts. nginx needs two listeners and a loopback hop
/// in the clear to do this.
#[tokio::test]
async fn decrypts_inspects_and_re_encrypts() {
let (cert, key) = self_signed();
let upstream = spawn_tls_echo(&cert, &key).await;
let addr = spawn_proxy(
upstream,
"",
TlsSetup {
server: Some(tls::server_config(&cert, &key).unwrap()),
upstream: Some(tls::client_config().unwrap()),
optional: false,
},
)
.await;
let got = tls_roundtrip(addr, b"end to end").await.unwrap();
assert_eq!(got, b"end to end");
}
/// The point of decrypting at all: a rule that could never have matched the
/// ciphertext blocks the connection.
#[tokio::test]
async fn rules_see_the_plaintext() {
let (cert, key) = self_signed();
let upstream = spawn_tls_echo(&cert, &key).await;
let addr = spawn_proxy(
upstream,
"block:FLAG{",
TlsSetup {
server: Some(tls::server_config(&cert, &key).unwrap()),
upstream: Some(tls::client_config().unwrap()),
optional: false,
},
)
.await;
let got = tls_roundtrip(addr, b"harmless request").await.unwrap();
assert_eq!(got, b"harmless request");
// Blocked, and closed properly: over TLS an abrupt drop reaches the peer as a
// truncation error, which is exactly the "left to guess" this engine avoids.
let got = tls_roundtrip(addr, b"give me FLAG{secret}")
.await
.expect("a blocked TLS connection was torn down instead of closed");
assert!(
got.is_empty(),
"a rule missed the decrypted payload: {got:?}"
);
}
/// A certificate and key that do not go together is a configuration error, and has to
/// be one at startup rather than a listener that refuses every handshake.
#[tokio::test]
async fn a_mismatched_key_is_refused() {
let (cert, _) = self_signed();
let (_, other_key) = self_signed();
let err = tls::server_config(&cert, &other_key).unwrap_err();
assert!(err.contains("do not go together"), "unhelpful error: {err}");
assert!(tls::server_config("", "").is_err());
}
/// TLS is off by default, and off must cost nothing: the plain path is the one the
/// benchmark measured.
#[tokio::test]
async fn tls_off_leaves_the_plain_path_alone() {
let upstream = spawn_plain_echo().await;
let addr = spawn_proxy(upstream, "", TlsSetup::default()).await;
assert!(TlsSetup::default().is_off());
let mut sock = TcpStream::connect(addr).await.unwrap();
sock.write_all(b"plain as ever").await.unwrap();
let mut buf = vec![0u8; 1024];
let n = sock.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"plain as ever");
let _ = Arc::new(());
}