diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b68c48..e93210d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,64 @@ All notable changes to Pollux will be documented in this file. +## [Unreleased] + +### Security + +- **Fixed a permanent denial of service from a single aborted connection**: 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 + refused with `41 Server unavailable` until the process was restarted. A client + that completed the TLS handshake and disconnected without sending a request — + a port scan, a health check, a cancelled page load — was enough. The counter is + replaced by a semaphore permit that is released on every exit path. +- **Fixed a remote crash through file-descriptor exhaustion**: an error from + `accept()` propagated out of `main` and ended the process. Accept errors are + now logged and retried after a short backoff, so the listener survives. +- **Added a TLS handshake timeout (10s) and a connection limit** + (`max_connections`, default 512): a peer that opened a socket and never sent a + ClientHello previously held a task and descriptor indefinitely. +- **Fixed unbounded memory use when serving files**: responses were read whole + into memory, so cost scaled with concurrent requests times file size (eight + requests for a 144 MB file reached 1.18 GB resident). Responses now stream in + 64 KiB chunks and are capped at 64 MiB, refused with `50` above that. +- **Fixed nondeterministic TLS certificate selection**: all virtual hosts were + 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. +- **Stopped serving hidden files**: dotfiles and dot-directories inside a content + root were public, exposing `.git/config` and any credentials in it for capsule + roots that are git working copies. +- **Replaced the `path-security` dependency** with an in-tree check (reject + non-plain path components, then compare canonical prefixes). Same guarantees + against traversal and symlink escape, no third-party code in the security + boundary, and no false positives on filenames containing `~`, `$` or `%`. +- **Upgraded rustls 0.21 → 0.23**; the 0.21 branch is end-of-life and no longer + receives security fixes. TLS 1.2 and 1.3 only, as before. +- **Bounded and downgraded request logging**: malformed requests logged + attacker-controlled text at `ERROR` on every request, letting a client drive + log volume. Client-caused conditions now log at `debug` and logged request + text is truncated. + +### Fixed + +- Hostname routing now accepts the authority forms the Gemini specification + permits: mixed case, an explicit port, userinfo, and a fully-qualified trailing + dot. These previously returned `53 Proxy request refused`. +- File extensions are matched case-insensitively, so `PHOTO.JPEG` is served as + `image/jpeg` rather than `application/octet-stream`. +- Per-host `port` and `log_level` were accepted and silently ignored; Pollux now + warns at startup that they have no effect. +- Response reads and writes have a 60s timeout, so a client that stops reading + cannot hold a concurrency permit open indefinitely. + +### Removed + +- Dead `src/logging.rs` stub, the unused `parse_gemini_url` duplicate URL parser, + and the unused `time` dependency. + ## [1.0.0] - 2026-01-17 ### Added diff --git a/Cargo.toml b/Cargo.toml index a98954c..32c7bb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,17 +6,21 @@ description = "A Gemini server for serving static content" [dependencies] tokio = { version = "1", features = ["full"] } -rustls = "0.21" -rustls-pemfile = "1.0" -tokio-rustls = "0.24" +# ring rather than the default aws-lc-rs backend: no cmake/C toolchain needed. +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", + "tls12", + "logging", +] } +rustls-pemfile = "2" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } clap = { version = "4.0", features = ["derive"] } -path-security = "0.2" toml = "0.8" serde = { version = "1.0", features = ["derive"] } urlencoding = "2.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi"] } -time = "0.3" [dev-dependencies] tempfile = "3" \ No newline at end of file diff --git a/README.md b/README.md index 24db44b..47160bd 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Create a config file at `/etc/pollux/config.toml` or use `--config` to specify a # Global settings (optional) bind_host = "0.0.0.0" port = 1965 -log_level = "info" -max_concurrent_requests = 1000 +max_concurrent_requests = 1000 # requests processed at once (default 1000) +max_connections = 512 # connections handled at once (default 512) # Virtual host configurations ["example.com"] @@ -46,17 +46,29 @@ key = "/etc/ssl/blog.key" root = "/var/gemini/another" cert = "/etc/ssl/another.crt" key = "/etc/ssl/another.key" -port = 1966 # Optional per-host port override ``` +Set the log level with the `RUST_LOG` environment variable (for example +`RUST_LOG=info`) rather than in the config file. + +Keep `max_connections` comfortably below the process file-descriptor limit +(`ulimit -n`, or `LimitNOFILE` in the systemd unit). Connections above the +limit are dropped immediately, which keeps the listener responsive. + +> **Not implemented:** per-host `port` and `log_level` keys are still parsed for +> backwards compatibility but have no effect — every host is served on the +> single global port. Pollux logs a warning at startup if it sees them. + ### Features - **Multiple hostnames** on a single server instance -- **Per-host TLS certificates** for proper security isolation +- **Per-host TLS certificates** selected by SNI, so each capsule presents its own identity - **Automatic content isolation** - each host serves only its own files -- **Path security** - directory traversal attacks are blocked +- **Path security** - directory traversal and symlink escapes are blocked +- **Hidden files are private** - dotfiles and dot-directories (`.git`, `.env`) are never served +- **Streamed responses** - memory use is independent of file size, capped at 64 MiB per response - **Index file serving** - `index.gmi` files are served automatically -- **Hostname validation** - DNS-compliant hostname checking +- **Hostname validation** - DNS-compliant hostname checking, matched case-insensitively ### Request Routing @@ -75,7 +87,6 @@ key = "/path/to/key.pem" hostname = "gemini.example.com" bind_host = "0.0.0.0" port = 1965 -log_level = "info" max_concurrent_requests = 1000 ``` diff --git a/dist/config.toml b/dist/config.toml index 2650430..008333a 100644 --- a/dist/config.toml +++ b/dist/config.toml @@ -28,21 +28,26 @@ port = 1965 # Request limiting # -# max_concurrent_requests: Maximum number of simultaneous connections -# - Prevents server overload and DoS attacks -# - Set to 0 to disable limiting (not recommended) +# max_concurrent_requests: Maximum number of requests processed at once +# - Requests above the limit are answered with "41 Server unavailable" +# - Must be between 1 and 1000000; the server will not start on 0 # - Typical values: 100-10000 depending on server capacity max_concurrent_requests = 1000 +# max_connections: Maximum number of connections handled at once +# - Connections above the limit are dropped immediately, which keeps the +# listener responsive under a flood of half-open connections +# - Keep this below the process file-descriptor limit (LimitNOFILE in the +# systemd unit, or `ulimit -n`) +# - Must be between 1 and 1000000; defaults to 512 if unset +max_connections = 512 + # Logging configuration # -# log_level: Controls how much information is logged -# - "error": Only errors that prevent normal operation -# - "warn": Errors plus warnings about unusual conditions -# - "info": General operational information (recommended) -# - "debug": Detailed debugging information -# - "trace": Very verbose debugging (use only for troubleshooting) -log_level = "info" +# Logging is controlled by the RUST_LOG environment variable, not this file. +# In the systemd unit, set for example: +# Environment=RUST_LOG=info +# Levels, least to most verbose: error, warn, info, debug, trace # Host configuration # Each hostname needs its own section with root, cert, and key settings diff --git a/examples/development.toml b/examples/development.toml index 183cd32..ed15c66 100644 --- a/examples/development.toml +++ b/examples/development.toml @@ -5,7 +5,7 @@ bind_host = "127.0.0.1" port = 1965 -log_level = "debug" +# Log level is set with the RUST_LOG environment variable, e.g. RUST_LOG=info (debug) max_concurrent_requests = 100 # Local development site diff --git a/examples/single-host.toml b/examples/single-host.toml index 6873ea5..831701a 100644 --- a/examples/single-host.toml +++ b/examples/single-host.toml @@ -6,7 +6,7 @@ # Global settings bind_host = "127.0.0.1" port = 1965 -log_level = "info" +# Log level is set with the RUST_LOG environment variable, e.g. RUST_LOG=info (info) max_concurrent_requests = 100 # Host configuration diff --git a/examples/virtual-hosting.toml b/examples/virtual-hosting.toml index bf425fd..108e1ed 100644 --- a/examples/virtual-hosting.toml +++ b/examples/virtual-hosting.toml @@ -6,7 +6,7 @@ # Global settings (applied to all hosts unless overridden) bind_host = "0.0.0.0" port = 1965 -log_level = "info" +# Log level is set with the RUST_LOG environment variable, e.g. RUST_LOG=info (info) max_concurrent_requests = 1000 # Main website @@ -27,9 +27,8 @@ root = "/home/user/public_gemini" cert = "/etc/ssl/tilde.crt" key = "/etc/ssl/tilde.key" -# Development site (different port) +# Development site ["dev.example.com"] root = "/home/dev/gemini" cert = "/etc/ssl/dev.crt" key = "/etc/ssl/dev.key" -port = 1966 \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 73f8db1..156f12b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,8 +8,9 @@ pub struct Config { pub bind_host: Option, pub port: Option, pub max_concurrent_requests: Option, + pub max_connections: Option, - // Per-hostname configurations + // Per-hostname configurations, keyed by lowercase hostname pub hosts: HashMap, } @@ -34,6 +35,16 @@ pub fn load_config(path: &str) -> Result> { let bind_host = extract_string(&toml_value, "bind_host"); let port = extract_u16(&toml_value, "port"); let max_concurrent_requests = extract_usize(&toml_value, "max_concurrent_requests"); + let max_connections = extract_usize(&toml_value, "max_connections"); + + // Logging is driven by RUST_LOG. Older configs set this key and it never had + // any effect, so say so instead of ignoring it. + if extract_string(&toml_value, "log_level").is_some() { + tracing::warn!( + "'log_level' in the config file is not implemented and is ignored; \ + set the RUST_LOG environment variable instead (for example RUST_LOG=info)" + ); + } // Extract host configurations let mut hosts = HashMap::new(); @@ -43,7 +54,7 @@ pub fn load_config(path: &str) -> Result> { // Skip global config keys if matches!( key.as_str(), - "bind_host" | "port" | "max_concurrent_requests" + "bind_host" | "port" | "max_concurrent_requests" | "max_connections" | "log_level" ) { continue; } @@ -80,6 +91,24 @@ pub fn load_config(path: &str) -> Result> { return Err(format!("Error for host '{}': Key file '{}' does not exist\nGenerate or obtain TLS certificates for your domain", key, key_path).into()); } + // These are accepted for backwards compatibility but have never + // been implemented. Say so, rather than letting an operator + // believe a host is isolated on its own port when it is not. + if port_override.is_some() { + tracing::warn!( + "[{}]: per-host 'port' is not implemented and is ignored; \ + every host is served on the global port", + key + ); + } + if log_level_override.is_some() { + tracing::warn!( + "[{}]: per-host 'log_level' is not implemented and is ignored; \ + use the RUST_LOG environment variable instead", + key + ); + } + let host_config = HostConfig { root, cert, @@ -88,7 +117,9 @@ pub fn load_config(path: &str) -> Result> { log_level: log_level_override, }; - hosts.insert(key.clone(), host_config); + // Keyed lowercase so routing matches the case-insensitive + // hostname the request parser produces. + hosts.insert(key.to_ascii_lowercase(), host_config); } } } @@ -102,6 +133,7 @@ pub fn load_config(path: &str) -> Result> { bind_host, port, max_concurrent_requests, + max_connections, hosts, }) } diff --git a/src/logging.rs b/src/logging.rs deleted file mode 100644 index 98c8cea..0000000 --- a/src/logging.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Logging module - now unused as logging is handled directly in main.rs -// All logging functionality moved to main.rs with RUST_LOG environment variable support - -#[cfg(test)] -mod tests { - #[test] - fn test_basic_functionality() { - // Basic test to ensure logging module compiles - assert!(true); - } -} diff --git a/src/main.rs b/src/main.rs index 6bfbe3b..8a3dd4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,38 +1,118 @@ mod config; -mod logging; mod request; mod server; mod tls; use clap::Parser; +use rustls::crypto::ring::sign::any_supported_type; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; use rustls::ServerConfig; +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use tokio::time::{timeout, Duration}; use tokio_rustls::TlsAcceptor; use tracing_subscriber::EnvFilter; +/// A TLS handshake must complete within this window. Without a bound, a peer +/// that opens a socket and never sends a ClientHello pins a file descriptor for +/// as long as it likes. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default ceiling on connections being handled at once. Keeps descriptor use +/// well under the usual 1024 soft limit, so exhausting it cannot stop the +/// listener from accepting. +const DEFAULT_MAX_CONNECTIONS: usize = 512; + +/// Selects each virtual host's own certificate from the SNI name. +/// +/// Previously every host was served whichever certificate happened to be first +/// in a `HashMap`, which varies per process. Gemini clients pin certificates on +/// first use, so an identity that changes between restarts trains users to +/// dismiss the mismatch warning that would otherwise reveal interception. +#[derive(Debug)] +struct SniCertResolver { + by_host: HashMap>, + default: Arc, +} + +impl ResolvesServerCert for SniCertResolver { + fn resolve(&self, client_hello: ClientHello) -> Option> { + let selected = client_hello + .server_name() + .and_then(|name| self.by_host.get(&name.to_ascii_lowercase())) + .cloned() + // An unknown or absent SNI name still gets a completed handshake so + // the request can be refused with a Gemini 53 rather than a TLS + // alert, which is far easier to diagnose. + .unwrap_or_else(|| self.default.clone()); + Some(selected) + } +} + fn create_tls_config( - hosts: &std::collections::HashMap, + hosts: &HashMap, ) -> Result, Box> { - // For Phase 3, we'll use the first host's certificate for all connections - // TODO: Phase 4 could implement proper SNI-based certificate selection - let first_host = hosts.values().next().ok_or("No hosts configured")?; + // Sorted, so the fallback certificate is stable across restarts instead of + // depending on hash iteration order. + let mut hostnames: Vec<&String> = hosts.keys().collect(); + hostnames.sort(); - let certs = tls::load_certs(&first_host.cert)?; - let key = tls::load_private_key(&first_host.key)?; + let mut by_host = HashMap::new(); + let mut default: Option> = None; - let config = ServerConfig::builder() - .with_safe_defaults() + for hostname in hostnames { + let host_config = &hosts[hostname]; + + let chain = tls::load_certs(&host_config.cert).map_err(|e| { + format!( + "Cannot load certificate '{}' for host '{}': {}", + host_config.cert, hostname, e + ) + })?; + if chain.is_empty() { + return Err(format!( + "Certificate file '{}' for host '{}' contains no certificates", + host_config.cert, hostname + ) + .into()); + } + + let key = tls::load_private_key(&host_config.key).map_err(|e| { + format!( + "Cannot load private key '{}' for host '{}': {}", + host_config.key, hostname, e + ) + })?; + let signing_key = any_supported_type(&key).map_err(|e| { + format!( + "Unsupported private key '{}' for host '{}': {}", + host_config.key, hostname, e + ) + })?; + + let certified = Arc::new(CertifiedKey::new(chain, signing_key)); + if default.is_none() { + default = Some(Arc::clone(&certified)); + } + by_host.insert(hostname.to_ascii_lowercase(), certified); + } + + let default = default.ok_or("No hosts configured")?; + + let server_config = ServerConfig::builder() .with_no_client_auth() - .with_single_cert(certs, key)?; + .with_cert_resolver(Arc::new(SniCertResolver { by_host, default })); - Ok(Arc::new(config)) + Ok(Arc::new(server_config)) } fn print_startup_info( config: &config::Config, - hosts: &std::collections::HashMap, + hosts: &HashMap, quiet: bool, ) { if quiet { @@ -205,6 +285,12 @@ async fn main() -> Result<(), Box> { std::process::exit(1); } + let max_connections = config.max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS); + if max_connections == 0 || max_connections > 1_000_000 { + tracing::error!("max_connections must be between 1 and 1,000,000"); + std::process::exit(1); + } + // TESTING ONLY: Read delay argument (debug builds only) #[cfg(debug_assertions)] let test_processing_delay = args @@ -238,32 +324,52 @@ async fn main() -> Result<(), Box> { ); } - loop { - let (stream, _) = listener.accept().await?; + // Shared once rather than cloned per connection. + let hosts = Arc::new(config.hosts); + let request_limiter = Arc::new(Semaphore::new(max_concurrent_requests)); + let connection_limiter = Arc::new(Semaphore::new(max_connections)); - let hosts_clone = config.hosts.clone(); - let acceptor_clone = acceptor.clone(); - let max_concurrent = max_concurrent_requests; + loop { + let (stream, peer) = match listener.accept().await { + Ok(accepted) => accepted, + Err(e) => { + // Descriptor exhaustion and aborted connections are transient. + // Propagating the error here used to end the process, turning a + // burst of half-open connections into a permanent outage. + tracing::warn!("Accept failed: {}", e); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + + let Ok(connection_permit) = Arc::clone(&connection_limiter).try_acquire_owned() else { + tracing::warn!("Connection limit reached, dropping connection from {}", peer); + continue; // stream is dropped here, closing the socket + }; + + let acceptor = acceptor.clone(); + let hosts = Arc::clone(&hosts); + let request_limiter = Arc::clone(&request_limiter); let test_delay = test_processing_delay; tokio::spawn(async move { - // TLS connection with hostname routing - match acceptor_clone.accept(stream).await { - Ok(tls_stream) => { + let _connection_permit = connection_permit; + + match timeout(HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await { + Ok(Ok(tls_stream)) => { if let Err(e) = server::handle_connection( tls_stream, - &hosts_clone, - max_concurrent, + &hosts, + &request_limiter, test_delay, ) .await { - tracing::error!("Error handling connection: {}", e); + tracing::debug!("Error handling connection from {}: {}", peer, e); } } - Err(e) => { - tracing::error!("TLS handshake failed: {}", e); - } + Ok(Err(e)) => tracing::debug!("TLS handshake with {} failed: {}", peer, e), + Err(_) => tracing::debug!("TLS handshake with {} timed out", peer), } }); } diff --git a/src/request.rs b/src/request.rs index 3fc591c..14b2c47 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,87 +1,67 @@ -use path_security::validate_path; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; #[derive(Debug, PartialEq)] pub enum PathResolutionError { NotFound, } -#[allow(dead_code)] -pub fn parse_gemini_url(request: &str, hostname: &str, expected_port: u16) -> Result { - if let Some(url) = request.strip_prefix("gemini://") { - let host_port_end = url.find('/').unwrap_or(url.len()); - let host_port = &url[..host_port_end]; +/// Map a request path onto a file inside `dir`. +/// +/// The returned path is canonical and always lives under the canonical `dir`. +/// Traversal is refused lexically before any filesystem access, and symlinks +/// that would leave the content root are caught afterwards by comparing +/// canonical prefixes. Components starting with `.` are refused so that +/// repository metadata and dotfiles inside a content root stay private. +pub fn resolve_file_path(path: &str, dir: &str) -> Result { + // Strip exactly one leading slash: a path like "//etc/passwd" must stay + // absolute so the component check below rejects it. + let relative = match path { + "" | "/" => "index.gmi".to_string(), + p if p.ends_with('/') => format!("{}index.gmi", p.strip_prefix('/').unwrap_or(p)), + p => p.strip_prefix('/').unwrap_or(p).to_string(), + }; + let relative = Path::new(&relative); - // Parse host and port - let (host, port_str) = if let Some(colon_pos) = host_port.find(':') { - let host = &host_port[..colon_pos]; - let port_str = &host_port[colon_pos + 1..]; - (host, Some(port_str)) - } else { - (host_port, None) - }; - - // Validate host - if host != hostname { - return Err(()); // Hostname mismatch + // Only plain names may appear. This rejects "..", ".", root prefixes and + // (on Windows) drive and UNC prefixes. + for component in relative.components() { + match component { + Component::Normal(name) => { + if name.to_string_lossy().starts_with('.') { + return Err(PathResolutionError::NotFound); + } + } + _ => return Err(PathResolutionError::NotFound), } - - // Validate port - let port = port_str.and_then(|p| p.parse::().ok()).unwrap_or(1965); - if port != expected_port { - return Err(()); // Port mismatch - } - - let path = if host_port_end < url.len() { - &url[host_port_end..] - } else { - "/" - }; - Ok(path.trim().to_string()) - } else { - Err(()) } + + let base = std::fs::canonicalize(dir).map_err(|_| PathResolutionError::NotFound)?; + let resolved = + std::fs::canonicalize(base.join(relative)).map_err(|_| PathResolutionError::NotFound)?; + + if !resolved.starts_with(&base) { + return Err(PathResolutionError::NotFound); + } + + Ok(resolved) } -pub fn resolve_file_path(path: &str, dir: &str) -> Result { - let file_path_str = if path == "/" { - "index.gmi".to_string() - } else if path.ends_with('/') { - format!("{}index.gmi", &path[1..]) - } else { - path[1..].to_string() +pub fn get_mime_type(file_path: &Path) -> &'static str { + let Some(ext) = file_path.extension() else { + return "application/octet-stream"; }; - match validate_path(Path::new(&file_path_str), Path::new(dir)) { - Ok(safe_path) => { - // Path is secure, now check if file exists - if safe_path.exists() { - Ok(safe_path) - } else { - Err(PathResolutionError::NotFound) - } - } - Err(_) => { - // Path validation failed - treat as not found - Err(PathResolutionError::NotFound) - } - } -} - -pub fn get_mime_type(file_path: &Path) -> &str { - if let Some(ext) = file_path.extension() { - match ext.to_str() { - Some("gmi") => "text/gemini", - Some("txt") => "text/plain", - Some("html") => "text/html", - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("webp") => "image/webp", - Some("gif") => "image/gif", - _ => "application/octet-stream", - } - } else { - "application/octet-stream" + match ext.to_string_lossy().to_ascii_lowercase().as_str() { + "gmi" => "text/gemini", + "txt" => "text/plain", + "html" | "htm" => "text/html", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "pdf" => "application/pdf", + _ => "application/octet-stream", } } @@ -90,36 +70,9 @@ mod tests { use super::*; use tempfile::TempDir; - #[test] - fn test_parse_gemini_url_valid() { - assert_eq!( - parse_gemini_url("gemini://gemini.jeena.net/", "gemini.jeena.net", 1965), - Ok("/".to_string()) - ); - assert_eq!( - parse_gemini_url( - "gemini://gemini.jeena.net/posts/test", - "gemini.jeena.net", - 1965 - ), - Ok("/posts/test".to_string()) - ); - } - - #[test] - fn test_parse_gemini_url_invalid_host() { - assert!(parse_gemini_url("gemini://foo.com/", "gemini.jeena.net", 1965).is_err()); - } - - #[test] - fn test_parse_gemini_url_no_prefix() { - assert!(parse_gemini_url("http://gemini.jeena.net/", "gemini.jeena.net", 1965).is_err()); - } - #[test] fn test_resolve_file_path_root() { let temp_dir = TempDir::new().unwrap(); - // Create index.gmi file since we now check for existence std::fs::write(temp_dir.path().join("index.gmi"), "# Test").unwrap(); assert!(resolve_file_path("/", temp_dir.path().to_str().unwrap()).is_ok()); } @@ -139,46 +92,149 @@ mod tests { assert!(resolve_file_path("/test.gmi", temp_dir.path().to_str().unwrap()).is_ok()); } - #[test] - fn test_resolve_file_path_traversal() { - let temp_dir = TempDir::new().unwrap(); - assert_eq!( - resolve_file_path("/../etc/passwd", temp_dir.path().to_str().unwrap()), - Err(PathResolutionError::NotFound) - ); - } - #[test] fn test_resolve_file_path_not_found() { let temp_dir = TempDir::new().unwrap(); - // Don't create the file, should return NotFound error assert_eq!( resolve_file_path("/nonexistent.gmi", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound) ); } + #[test] + fn test_resolve_file_path_nested_file() { + let temp_dir = TempDir::new().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("a/b")).unwrap(); + std::fs::write(temp_dir.path().join("a/b/c.gmi"), "# Test").unwrap(); + assert!(resolve_file_path("/a/b/c.gmi", temp_dir.path().to_str().unwrap()).is_ok()); + } + + /// Every traversal shape must fail, including the encoded forms the server + /// has already percent-decoded by the time it reaches us. + #[test] + fn test_resolve_file_path_traversal_rejected() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(temp_dir.path().join("secret.txt"), "SECRET").unwrap(); + let dir = root.to_str().unwrap(); + + for attack in [ + "/../secret.txt", + "/../../secret.txt", + "/a/../../secret.txt", + "/./../secret.txt", + "//etc/passwd", + "/etc/../etc/passwd", + "/..", + "/.", + ] { + assert_eq!( + resolve_file_path(attack, dir), + Err(PathResolutionError::NotFound), + "traversal not blocked: {}", + attack + ); + } + } + + /// A symlink inside the root that points outside it must not be served. + #[cfg(unix)] + #[test] + fn test_resolve_file_path_symlink_escape_rejected() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let outside = temp_dir.path().join("secret.txt"); + std::fs::write(&outside, "SECRET").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape.gmi")).unwrap(); + + assert_eq!( + resolve_file_path("/escape.gmi", root.to_str().unwrap()), + Err(PathResolutionError::NotFound) + ); + } + + /// A symlink that stays inside the root is legitimate and must still work. + #[cfg(unix)] + #[test] + fn test_resolve_file_path_symlink_inside_root_allowed() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("real.gmi"), "# Real").unwrap(); + std::os::unix::fs::symlink( + temp_dir.path().join("real.gmi"), + temp_dir.path().join("link.gmi"), + ) + .unwrap(); + + assert!(resolve_file_path("/link.gmi", temp_dir.path().to_str().unwrap()).is_ok()); + } + + /// Dotfiles and dot-directories inside a content root are private: capsule + /// roots are often git working copies, which would otherwise expose + /// .git/config and any credentials in it. + #[test] + fn test_resolve_file_path_hidden_files_rejected() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join(".env"), "SECRET=1").unwrap(); + std::fs::create_dir(temp_dir.path().join(".git")).unwrap(); + std::fs::write(temp_dir.path().join(".git/config"), "[remote]").unwrap(); + let dir = temp_dir.path().to_str().unwrap(); + + for hidden in ["/.env", "/.git/config", "/.git/"] { + assert_eq!( + resolve_file_path(hidden, dir), + Err(PathResolutionError::NotFound), + "hidden path served: {}", + hidden + ); + } + } + + /// Filenames the previous validator rejected as "suspicious" are ordinary + /// content and must be reachable. + #[test] + fn test_resolve_file_path_allows_unusual_but_valid_names() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path().to_str().unwrap(); + + for name in ["~backup.gmi", "price$5.gmi", "100%-done.gmi", "café.gmi"] { + std::fs::write(temp_dir.path().join(name), "# ok").unwrap(); + assert!( + resolve_file_path(&format!("/{}", name), dir).is_ok(), + "legitimate filename rejected: {}", + name + ); + } + } + #[test] fn test_get_mime_type_gmi() { - let path = Path::new("test.gmi"); - assert_eq!(get_mime_type(path), "text/gemini"); + assert_eq!(get_mime_type(Path::new("test.gmi")), "text/gemini"); } #[test] fn test_get_mime_type_png() { - let path = Path::new("test.png"); - assert_eq!(get_mime_type(path), "image/png"); + assert_eq!(get_mime_type(Path::new("test.png")), "image/png"); } #[test] fn test_get_mime_type_unknown() { - let path = Path::new("test.xyz"); - assert_eq!(get_mime_type(path), "application/octet-stream"); + assert_eq!( + get_mime_type(Path::new("test.xyz")), + "application/octet-stream" + ); } #[test] fn test_get_mime_type_no_extension() { - let path = Path::new("test"); - assert_eq!(get_mime_type(path), "application/octet-stream"); + assert_eq!(get_mime_type(Path::new("test")), "application/octet-stream"); + } + + /// Extensions are matched case-insensitively; UPPER.GMI is still Gemini text. + #[test] + fn test_get_mime_type_is_case_insensitive() { + assert_eq!(get_mime_type(Path::new("UPPER.GMI")), "text/gemini"); + assert_eq!(get_mime_type(Path::new("Photo.JPEG")), "image/jpeg"); } } diff --git a/src/server.rs b/src/server.rs index 3a06e83..5c3f843 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,183 +1,373 @@ use crate::request::{get_mime_type, resolve_file_path}; -use std::fs; +use std::collections::HashMap; use std::io; use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::sync::Semaphore; use tokio::time::{timeout, Duration}; use tokio_rustls::server::TlsStream; -static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0); +/// Longest request line accepted: a 1024-byte URL plus CRLF. +const MAX_REQUEST_SIZE: usize = 1026; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -/// Extract hostname and path from a Gemini URL -/// Returns (hostname, path) or error for invalid URLs -pub fn extract_hostname_and_path(request: &str) -> Result<(String, String), ()> { - if !request.starts_with("gemini://") { - return Err(()); +/// Largest file served in a single response. Bounds how much a slow client can +/// make the server buffer, and keeps a stray large file out of a capsule. +const MAX_FILE_SIZE: u64 = 64 * 1024 * 1024; + +/// Response body chunk size. Memory use per request is this, not the file size. +const CHUNK_SIZE: usize = 64 * 1024; + +/// Applies to each read and write, so a client that stops reading cannot hold a +/// concurrency permit open indefinitely. +const IO_TIMEOUT: Duration = Duration::from_secs(60); + +/// Keep attacker-controlled text out of the logs beyond a bounded length. +fn truncate(request: &str) -> String { + const LIMIT: usize = 128; + if request.chars().count() <= LIMIT { + return request.to_string(); } + let head: String = request.chars().take(LIMIT).collect(); + format!("{}… ({} bytes total)", head, request.len()) +} + +/// Extract the routing hostname and the decoded path from a Gemini URL. +/// +/// The authority is folded to the canonical DNS form clients may legitimately +/// vary: userinfo and port are stripped, a fully-qualified trailing dot is +/// removed, and the result is lowercased. Without this, a spec-conformant +/// request such as `gemini://Example.com:1965/` would be refused. +pub fn extract_hostname_and_path(request: &str) -> Result<(String, String), ()> { + let url_part = request.strip_prefix("gemini://").ok_or(())?; - let url_part = &request[9..]; // Remove "gemini://" prefix let slash_pos = url_part.find('/').unwrap_or(url_part.len()); - - let hostname = &url_part[..slash_pos]; + let authority = &url_part[..slash_pos]; let path = if slash_pos < url_part.len() { - url_part[slash_pos..].to_string() + &url_part[slash_pos..] } else { - "/".to_string() + "/" }; - // Basic hostname validation - if hostname.is_empty() || hostname.contains('/') { + // Discard any userinfo ahead of the host. + let authority = match authority.rsplit_once('@') { + Some((_, host)) => host, + None => authority, + }; + + // Strip the optional port, taking care not to split inside an IPv6 literal. + let host = if authority.starts_with('[') { + match authority.find(']') { + Some(end) => &authority[..=end], + None => return Err(()), + } + } else { + match authority.rsplit_once(':') { + Some((host, _port)) => host, + None => authority, + } + }; + + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() || host.contains('/') { return Err(()); } - // URL decode the path - let decoded_path = urlencoding::decode(&path).map_err(|_| ())?; + let decoded_path = urlencoding::decode(path).map_err(|_| ())?; - Ok((hostname.to_string(), decoded_path.to_string())) + Ok((host, decoded_path.to_string())) +} + +/// Read one CRLF-terminated request line, bounded in both size and time. +async fn read_request(stream: &mut TlsStream) -> io::Result { + let read = async { + let mut buf = Vec::with_capacity(MAX_REQUEST_SIZE); + let mut byte = [0u8; 1]; + loop { + if buf.len() >= MAX_REQUEST_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "request line too long", + )); + } + stream.read_exact(&mut byte).await?; + buf.push(byte[0]); + if buf.ends_with(b"\r\n") { + break; + } + } + Ok(buf) + }; + + let buf = timeout(REQUEST_TIMEOUT, read) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "request timed out"))??; + + Ok(String::from_utf8_lossy(&buf).trim().to_string()) } pub async fn handle_connection( mut stream: TlsStream, - hosts: &std::collections::HashMap, - max_concurrent_requests: usize, + hosts: &HashMap, + request_limiter: &Semaphore, _test_processing_delay: u64, ) -> io::Result<()> { - const MAX_REQUEST_SIZE: usize = 1026; - const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); - - let mut request_buf = Vec::new(); - let read_future = async { - loop { - if request_buf.len() >= MAX_REQUEST_SIZE { - return Err(tokio::io::Error::new( - tokio::io::ErrorKind::InvalidData, - "Request too large", - )); - } - let mut byte = [0; 1]; - stream.read_exact(&mut byte).await?; - request_buf.push(byte[0]); - if request_buf.ends_with(b"\r\n") { - break; - } + let request = match read_request(&mut stream).await { + Ok(request) => request, + Err(e) => { + tracing::debug!("Request read failed: {}", e); + return send_response(&mut stream, "59 Bad Request\r\n").await; } - Ok(()) }; - match timeout(REQUEST_TIMEOUT, read_future).await { - Ok(Ok(())) => { - // Read successful, continue processing - let request = String::from_utf8_lossy(&request_buf).trim().to_string(); - - // Initialize logger early for all request types - // TODO: Phase 3 - re-enable RequestLogger with proper TLS stream - // let logger = RequestLogger::new(&stream, request.clone()); - - // Check concurrent request limit after connection establishment - let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed); - if current >= max_concurrent_requests { - tracing::error!("Concurrent request limit exceeded"); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - // Rate limited - send proper 41 response - send_response(&mut stream, "41 Server unavailable\r\n").await?; - return Ok(()); - } - - // Process the request - // Validate request - if request.is_empty() { - tracing::error!("Empty request"); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - return send_response(&mut stream, "59 Bad Request\r\n").await; - } - - // Extract hostname and path - let (hostname, path) = match extract_hostname_and_path(&request) { - Ok(result) => result, - Err(_) => { - tracing::error!("Invalid URL format: {}", request); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - return send_response(&mut stream, "59 Bad Request\r\n").await; - } - }; - - // Route to appropriate host - let host_config = match hosts.get(&hostname) { - Some(config) => config, - None => { - tracing::error!("Unknown hostname: {}", hostname); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - return send_response(&mut stream, "53 Proxy request refused\r\n").await; - } - }; - - // TESTING ONLY: Add delay for rate limiting tests (debug builds only) - #[cfg(debug_assertions)] - if _test_processing_delay > 0 { - tokio::time::sleep(tokio::time::Duration::from_secs(_test_processing_delay)).await; - } - - // Resolve file path - let file_path = match resolve_file_path(&path, &host_config.root) { - Ok(path) => path, - Err(_) => { - tracing::error!("Path resolution failed for: {}", path); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - return send_response(&mut stream, "51 Not found\r\n").await; - } - }; - - tracing::info!("{} 20 Success", request); - - // Serve the file - match serve_file(&mut stream, &file_path, &request).await { - Ok(_) => {} - Err(e) => { - tracing::error!("Error serving file {}: {}", file_path.display(), e); - let _ = send_response(&mut stream, "51 Not found\r\n").await; - } - } - } - Ok(Err(e)) => { - tracing::error!("Request read error: {}", e); - let _ = send_response(&mut stream, "59 Bad Request\r\n").await; - } + // Held until this function returns, on every path including early returns + // and panics. Nothing here adjusts a counter by hand. + let _permit = match request_limiter.try_acquire() { + Ok(permit) => permit, Err(_) => { - tracing::error!("Request timeout"); - let _ = send_response(&mut stream, "59 Bad Request\r\n").await; + tracing::warn!("Concurrent request limit reached, refusing request"); + return send_response(&mut stream, "41 Server unavailable\r\n").await; } + }; + + if request.is_empty() { + tracing::debug!("Empty request"); + return send_response(&mut stream, "59 Bad Request\r\n").await; + } + + let (hostname, path) = match extract_hostname_and_path(&request) { + Ok(parsed) => parsed, + Err(_) => { + tracing::debug!("Invalid URL format: {}", truncate(&request)); + return send_response(&mut stream, "59 Bad Request\r\n").await; + } + }; + + let host_config = match hosts.get(&hostname) { + Some(config) => config, + None => { + tracing::debug!("Unknown hostname: {}", truncate(&hostname)); + return send_response(&mut stream, "53 Proxy request refused\r\n").await; + } + }; + + // TESTING ONLY: Add delay for rate limiting tests (debug builds only) + #[cfg(debug_assertions)] + if _test_processing_delay > 0 { + tokio::time::sleep(Duration::from_secs(_test_processing_delay)).await; + } + + let file_path = match resolve_file_path(&path, &host_config.root) { + Ok(file_path) => file_path, + Err(_) => { + tracing::debug!("Path resolution failed for: {}", truncate(&path)); + return send_response(&mut stream, "51 Not found\r\n").await; + } + }; + + // serve_file owns the whole response, including its own error statuses: + // once the header is on the wire a second status line cannot be sent. It + // reports back which status it used so the access log stays accurate. + match serve_file(&mut stream, &file_path).await { + Ok(status) => tracing::info!("{} {}", truncate(&request), status), + Err(e) => tracing::debug!("Error serving {}: {}", file_path.display(), e), } - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); Ok(()) } -async fn serve_file(stream: &mut S, file_path: &Path, _request: &str) -> io::Result<()> +/// Send a complete response for `file_path`, streaming the body. +/// +/// Exactly one status line is written, and the status used is returned so the +/// caller can log it. Errors after the header has been sent are reported to the +/// caller for logging only. +async fn serve_file(stream: &mut S, file_path: &Path) -> io::Result<&'static str> where S: AsyncWriteExt + Unpin, { - if file_path.exists() && file_path.is_file() { - let mime_type = get_mime_type(file_path); - let response_header = format!("20 {}\r\n", mime_type); - send_response(stream, &response_header).await?; + // Open first and stat the handle rather than the path, so the file cannot be + // swapped for a symlink between the check and the read. + let mut file = match tokio::fs::File::open(file_path).await { + Ok(file) => file, + Err(e) => { + tracing::debug!("Cannot open {}: {}", file_path.display(), e); + send_response(stream, "51 Not found\r\n").await?; + return Ok("51 Not found"); + } + }; - // Read and send file content - let content = fs::read(file_path)?; - stream.write_all(&content).await?; - stream.flush().await?; - } else { - return Err(io::Error::new(io::ErrorKind::NotFound, "File not found")); + let metadata = match file.metadata().await { + Ok(metadata) => metadata, + Err(e) => { + tracing::debug!("Cannot stat {}: {}", file_path.display(), e); + send_response(stream, "51 Not found\r\n").await?; + return Ok("51 Not found"); + } + }; + + // Directories and special files are not content. + if !metadata.is_file() { + send_response(stream, "51 Not found\r\n").await?; + return Ok("51 Not found"); } - Ok(()) + + if metadata.len() > MAX_FILE_SIZE { + tracing::warn!( + "Refusing to serve {}: {} bytes exceeds the {} byte limit", + file_path.display(), + metadata.len(), + MAX_FILE_SIZE + ); + send_response(stream, "50 Permanent failure\r\n").await?; + return Ok("50 Permanent failure"); + } + + send_response(stream, &format!("20 {}\r\n", get_mime_type(file_path))).await?; + + // Fixed-size chunks: peak memory is CHUNK_SIZE per request regardless of + // how large the file is or how slowly the client reads. + let mut buf = vec![0u8; CHUNK_SIZE]; + loop { + let read = timeout(IO_TIMEOUT, file.read(&mut buf)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "file read timed out"))??; + if read == 0 { + break; + } + timeout(IO_TIMEOUT, stream.write_all(&buf[..read])) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response write timed out"))??; + } + + timeout(IO_TIMEOUT, stream.flush()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response flush timed out"))??; + + Ok("20 Success") } async fn send_response(stream: &mut S, response: &str) -> io::Result<()> where S: AsyncWriteExt + Unpin, { - stream.write_all(response.as_bytes()).await?; - stream.flush().await?; + timeout(IO_TIMEOUT, stream.write_all(response.as_bytes())) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response write timed out"))??; + timeout(IO_TIMEOUT, stream.flush()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response flush timed out"))??; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_hostname_and_path_valid() { + assert_eq!( + extract_hostname_and_path("gemini://example.com/"), + Ok(("example.com".to_string(), "/".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://example.com"), + Ok(("example.com".to_string(), "/".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://example.com/page.gmi"), + Ok(("example.com".to_string(), "/page.gmi".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://sub.example.com/a/b.txt"), + Ok(("sub.example.com".to_string(), "/a/b.txt".to_string())) + ); + } + + #[test] + fn test_extract_hostname_and_path_invalid() { + for invalid in [ + "", + "http://example.com/", + "gemini://", + "//example.com/", + "gemini:///path", + ] { + assert!( + extract_hostname_and_path(invalid).is_err(), + "should reject: {}", + invalid + ); + } + } + + /// DNS names are case-insensitive, so routing must be too. + #[test] + fn test_extract_hostname_is_lowercased() { + assert_eq!( + extract_hostname_and_path("gemini://EXAMPLE.COM/x.gmi"), + Ok(("example.com".to_string(), "/x.gmi".to_string())) + ); + } + + /// The Gemini spec permits an explicit port; it must not change routing. + #[test] + fn test_extract_hostname_strips_port() { + assert_eq!( + extract_hostname_and_path("gemini://example.com:1965/"), + Ok(("example.com".to_string(), "/".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://example.com:1965/a.gmi"), + Ok(("example.com".to_string(), "/a.gmi".to_string())) + ); + } + + #[test] + fn test_extract_hostname_strips_userinfo_and_trailing_dot() { + assert_eq!( + extract_hostname_and_path("gemini://user@example.com/"), + Ok(("example.com".to_string(), "/".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://example.com./"), + Ok(("example.com".to_string(), "/".to_string())) + ); + } + + #[test] + fn test_extract_hostname_ipv6_literal() { + assert_eq!( + extract_hostname_and_path("gemini://[::1]:1965/x.gmi"), + Ok(("[::1]".to_string(), "/x.gmi".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://[::1]/"), + Ok(("[::1]".to_string(), "/".to_string())) + ); + } + + /// The path is percent-decoded, so encoded traversal reaches the path + /// validator in its decoded form and is rejected there. + #[test] + fn test_extract_path_is_percent_decoded() { + assert_eq!( + extract_hostname_and_path("gemini://example.com/%2e%2e%2fsecret"), + Ok(("example.com".to_string(), "/../secret".to_string())) + ); + assert_eq!( + extract_hostname_and_path("gemini://example.com/caf%C3%A9.gmi"), + Ok(("example.com".to_string(), "/café.gmi".to_string())) + ); + } + + #[test] + fn test_truncate_bounds_log_output() { + assert_eq!(truncate("short"), "short"); + let long = "a".repeat(500); + let truncated = truncate(&long); + assert!(truncated.starts_with(&"a".repeat(128))); + assert!(truncated.contains("500 bytes total")); + assert!(truncated.chars().count() < 200); + } +} diff --git a/src/tls.rs b/src/tls.rs index 04b4cff..bad2a69 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,31 +1,22 @@ +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use std::fs; use std::io::{self, BufReader}; -pub fn load_certs(filename: &str) -> io::Result> { +pub fn load_certs(filename: &str) -> io::Result>> { let certfile = fs::File::open(filename)?; let mut reader = BufReader::new(certfile); - rustls_pemfile::certs(&mut reader)? - .into_iter() - .map(|v| Ok(rustls::Certificate(v))) - .collect() + rustls_pemfile::certs(&mut reader).collect() } -pub fn load_private_key(filename: &str) -> io::Result { +pub fn load_private_key(filename: &str) -> io::Result> { let keyfile = fs::File::open(filename)?; let mut reader = BufReader::new(keyfile); - loop { - match rustls_pemfile::read_one(&mut reader)? { - Some(rustls_pemfile::Item::RSAKey(key)) => return Ok(rustls::PrivateKey(key)), - Some(rustls_pemfile::Item::PKCS8Key(key)) => return Ok(rustls::PrivateKey(key)), - Some(rustls_pemfile::Item::ECKey(key)) => return Ok(rustls::PrivateKey(key)), - None => break, - _ => {} - } - } - - Err(io::Error::new( - io::ErrorKind::InvalidData, - "No supported private key found", - )) + // Accepts PKCS#8, PKCS#1 (RSA) and SEC1 (EC) keys. + rustls_pemfile::private_key(&mut reader)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "no supported private key found in file", + ) + }) } diff --git a/tests/abort_connection.py b/tests/abort_connection.py new file mode 100644 index 0000000..db88d46 --- /dev/null +++ b/tests/abort_connection.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Connection-abort helper for regression tests. + +Completes a TLS handshake and then closes the connection without ever sending a +request line. This is the shape of traffic that used to underflow the server's +in-flight request counter and refuse every later request with "41". + +Usage: python3 tests/abort_connection.py [count] +Environment: GEMINI_PORT, GEMINI_CONNECT_HOST +""" + +import os +import socket +import ssl +import sys + + +def main(): + hostname = sys.argv[1] if len(sys.argv) > 1 else "localhost" + count = int(sys.argv[2]) if len(sys.argv) > 2 else 1 + + port = int(os.environ.get("GEMINI_PORT", "1965")) + connect_host = os.environ.get("GEMINI_CONNECT_HOST", hostname) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + aborted = 0 + for _ in range(count): + try: + sock = socket.create_connection((connect_host, port), timeout=5.0) + tls_sock = context.wrap_socket(sock, server_hostname=hostname) + # Close with no request bytes sent at all. + tls_sock.close() + aborted += 1 + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + print(f"aborted {aborted}") + + +if __name__ == "__main__": + main() diff --git a/tests/connection_lifecycle.rs b/tests/connection_lifecycle.rs new file mode 100644 index 0000000..002fe9e --- /dev/null +++ b/tests/connection_lifecycle.rs @@ -0,0 +1,218 @@ +//! 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 + ); + } + +} diff --git a/tests/gemini_test_client.py b/tests/gemini_test_client.py index f4ee198..a009b59 100755 --- a/tests/gemini_test_client.py +++ b/tests/gemini_test_client.py @@ -44,6 +44,15 @@ def main(): # Allow overriding the connection host (useful for testing with localhost) connect_host = os.environ.get('GEMINI_CONNECT_HOST', host) + # SNI carries a hostname only: never a port, and never userinfo. Passing + # "host:port" here makes the handshake fail before the server sees anything. + sni_host = host.rsplit('@', 1)[-1] + if sni_host.startswith('['): + sni_host = sni_host[:sni_host.index(']') + 1] if ']' in sni_host else sni_host + elif ':' in sni_host: + sni_host = sni_host.rsplit(':', 1)[0] + sni_host = sni_host.rstrip('.') + try: # Create SSL connection with permissive settings for self-signed certs context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) @@ -53,7 +62,7 @@ def main(): context.load_default_certs() sock = socket.create_connection((connect_host, port), timeout=5.0) - ssl_sock = context.wrap_socket(sock, server_hostname=host) + ssl_sock = context.wrap_socket(sock, server_hostname=sni_host) # Send request (full URL for Gemini protocol over TLS) request = f"{url}\r\n"