pollux/tests/connection_lifecycle.rs
Jeena 9ca54e98c9 Fix denial of service, information disclosure and TLS identity bugs
An adversarial review of the request path turned up nine issues, all
reproduced against a running server before and after the fix.

The most serious was a permanent denial of service. The in-flight request
counter was decremented on paths that never incremented it; being unsigned
it wrapped to usize::MAX, after which every request was answered with
"41 Server unavailable" until the process restarted. A client that completed
the TLS handshake and disconnected without sending a request was enough --
a port scan, a health check, a cancelled page load. The counter is replaced
by a semaphore permit released on every exit path.

Two further remote denials of service: an error from accept() propagated out
of main and ended the process, so exhausting the descriptor limit killed the
server; and responses were read whole into memory, so cost scaled with
concurrent requests times file size. Accept errors are now logged and retried
after a backoff, and responses stream in 64 KiB chunks capped at 64 MiB.
Peers that open a socket and never send a ClientHello are bounded by a 10s
handshake timeout and a connection cap (max_connections, default 512).

Every virtual host was served whichever certificate came first in a HashMap,
which varies per process, so a host's certificate changed between restarts.
Because Gemini clients pin certificates on first use, this trained users to
dismiss the mismatch warning that would otherwise reveal interception.
Certificates are now selected by SNI with a deterministic fallback, so
unknown hosts still complete a handshake and receive 53.

Dotfiles inside a content root were public, exposing .git/config and any
credentials in it for capsule roots that are git working copies. Path
resolution is now in-tree: reject non-plain components, then compare
canonical prefixes. This replaces path-security, an unaudited micro-crate in
the security boundary that also rejected legitimate filenames containing
'%', '~' or '$'.

rustls moves 0.21 -> 0.23; the 0.21 branch is end of life. TLS 1.2 and 1.3
only, as before.

Malformed requests logged attacker-controlled text at ERROR on every request,
letting a client drive log volume; client-caused conditions now log at debug,
truncated. Hostname routing accepts the authority forms the specification
permits (mixed case, explicit port, userinfo, trailing dot), which previously
returned 53. Per-host port and log_level were accepted and silently ignored,
and now warn at startup.

Adds tests/connection_lifecycle.rs covering the counter underflow, the
oversized-response refusal, hidden files and authority normalization. Fixes
the test client, which sent host:port as SNI. 42 -> 56 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 04:30:38 +00:00

218 lines
6.9 KiB
Rust

//! Regression tests for connection and request lifecycle handling.
//!
//! These cover remotely triggerable availability bugs rather than routing
//! behaviour: a server that stops answering is as broken as one that leaks.
mod common;
use std::process::{Child, Command};
use std::time::Duration;
/// Owns a server process and reaps it on drop.
///
/// A panicking assertion must not leave a live server behind: the orphan keeps
/// the test harness's stdout pipe open, so the whole run hangs instead of
/// reporting the failure.
struct ServerProcess(Child);
impl Drop for ServerProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn start_server(config_path: &std::path::Path) -> ServerProcess {
let child = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(config_path)
.arg("--quiet")
.spawn()
.expect("failed to start server");
std::thread::sleep(Duration::from_millis(600));
ServerProcess(child)
}
fn write_config(dir: &std::path::Path, root: &std::path::Path, port: u16) -> std::path::PathBuf {
let config_path = dir.join("config.toml");
let content = format!(
r#"
bind_host = "127.0.0.1"
port = {}
["localhost"]
root = "{}"
cert = "{}"
key = "{}"
"#,
port,
root.display(),
dir.join("cert.pem").display(),
dir.join("key.pem").display()
);
std::fs::write(&config_path, content).unwrap();
config_path
}
fn request(port: u16, url: &str) -> String {
let output = Command::new("python3")
.arg("tests/gemini_test_client.py")
.arg(url)
.env("GEMINI_PORT", port.to_string())
.env("GEMINI_CONNECT_HOST", "127.0.0.1")
.output()
.expect("failed to run test client");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn abort_connections(port: u16, count: usize) {
let output = Command::new("python3")
.arg("tests/abort_connection.py")
.arg("localhost")
.arg(count.to_string())
.env("GEMINI_PORT", port.to_string())
.env("GEMINI_CONNECT_HOST", "127.0.0.1")
.output()
.expect("failed to run abort helper");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("aborted {}", count)),
"abort helper did not complete: {}",
stdout
);
}
/// A client that completes the TLS handshake and disconnects without sending a
/// request must not affect later requests.
///
/// This previously decremented the in-flight request counter without a matching
/// increment. The counter is unsigned, so it wrapped to usize::MAX and every
/// subsequent request was refused with "41 Server unavailable" until restart.
/// One aborted connection was enough to take the capsule down permanently.
#[test]
fn test_aborted_connection_does_not_disable_server() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 2300 + (std::process::id() % 700) as u16;
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
let before = request(port, "gemini://localhost/test.gmi");
assert!(
before.starts_with("20"),
"baseline request should succeed, got: {}",
before
);
abort_connections(port, 1);
let after = request(port, "gemini://localhost/test.gmi");
assert!(
after.starts_with("20"),
"server refused a request after one aborted connection (counter underflow), got: {}",
after
);
// A sustained burst must not degrade it either.
abort_connections(port, 25);
let after_burst = request(port, "gemini://localhost/test.gmi");
assert!(
after_burst.starts_with("20"),
"server refused a request after 25 aborted connections, got: {}",
after_burst
);
}
/// Files above the response size limit are refused instead of being buffered
/// whole into memory, where concurrent requests multiplied the cost.
#[test]
fn test_oversized_file_is_refused() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 3100 + (std::process::id() % 700) as u16;
// Just over the 64 MiB response limit.
let big = vec![b'x'; 64 * 1024 * 1024 + 1024];
std::fs::write(root.join("big.gmi"), &big).unwrap();
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
let response = request(port, "gemini://localhost/big.gmi");
assert!(
response.starts_with("50"),
"oversized file should be refused with 50, got: {}",
response
);
// A normal file on the same server still works.
let ok = request(port, "gemini://localhost/test.gmi");
assert!(
ok.starts_with("20"),
"normal file should still serve, got: {}",
ok
);
}
/// Dotfiles are not served. Capsule roots are frequently git working copies, so
/// this is what keeps .git/config and any credentials in it private.
#[test]
fn test_hidden_files_are_not_served() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 3800 + (std::process::id() % 700) as u16;
std::fs::write(root.join(".env"), "SECRET_TOKEN=hunter2").unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git/config"), "[remote]\n url = secret").unwrap();
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
for hidden in ["gemini://localhost/.env", "gemini://localhost/.git/config"] {
let response = request(port, hidden);
assert!(
response.starts_with("51"),
"hidden path {} should not be served, got: {}",
hidden,
response
);
assert!(
!response.contains("hunter2") && !response.contains("secret"),
"hidden file contents leaked for {}: {}",
hidden,
response
);
}
}
/// Routing accepts the authority forms the Gemini specification permits:
/// mixed case, an explicit port, userinfo, and a fully-qualified trailing dot.
#[test]
fn test_authority_forms_are_normalized() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 4500 + (std::process::id() % 700) as u16;
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
for url in [
"gemini://localhost/test.gmi",
"gemini://LOCALHOST/test.gmi",
"gemini://localhost./test.gmi",
&format!("gemini://localhost:{}/test.gmi", port),
] {
let response = request(port, url);
assert!(
response.starts_with("20"),
"{} should be routed to the localhost vhost, got: {}",
url,
response
);
}
}