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>
This commit is contained in:
parent
7de660dbb6
commit
9ca54e98c9
16 changed files with 1052 additions and 338 deletions
58
CHANGELOG.md
58
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
|
||||
|
|
|
|||
14
Cargo.toml
14
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"
|
||||
25
README.md
25
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
|
||||
```
|
||||
|
||||
|
|
|
|||
25
dist/config.toml
vendored
25
dist/config.toml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -8,8 +8,9 @@ pub struct Config {
|
|||
pub bind_host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub max_concurrent_requests: Option<usize>,
|
||||
pub max_connections: Option<usize>,
|
||||
|
||||
// Per-hostname configurations
|
||||
// Per-hostname configurations, keyed by lowercase hostname
|
||||
pub hosts: HashMap<String, HostConfig>,
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,16 @@ pub fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
|
|||
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<Config, Box<dyn std::error::Error>> {
|
|||
// 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<Config, Box<dyn std::error::Error>> {
|
|||
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<Config, Box<dyn std::error::Error>> {
|
|||
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<Config, Box<dyn std::error::Error>> {
|
|||
bind_host,
|
||||
port,
|
||||
max_concurrent_requests,
|
||||
max_connections,
|
||||
hosts,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
158
src/main.rs
158
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<String, Arc<CertifiedKey>>,
|
||||
default: Arc<CertifiedKey>,
|
||||
}
|
||||
|
||||
impl ResolvesServerCert for SniCertResolver {
|
||||
fn resolve(&self, client_hello: ClientHello) -> Option<Arc<CertifiedKey>> {
|
||||
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<String, config::HostConfig>,
|
||||
hosts: &HashMap<String, config::HostConfig>,
|
||||
) -> Result<Arc<ServerConfig>, Box<dyn std::error::Error>> {
|
||||
// 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<Arc<CertifiedKey>> = 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<String, config::HostConfig>,
|
||||
hosts: &HashMap<String, config::HostConfig>,
|
||||
quiet: bool,
|
||||
) {
|
||||
if quiet {
|
||||
|
|
@ -205,6 +285,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
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<dyn std::error::Error>> {
|
|||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
294
src/request.rs
294
src/request.rs
|
|
@ -1,88 +1,68 @@
|
|||
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<String, ()> {
|
||||
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];
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Validate port
|
||||
let port = port_str.and_then(|p| p.parse::<u16>().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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PathBuf, PathResolutionError> {
|
||||
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()
|
||||
// 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);
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
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 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",
|
||||
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",
|
||||
}
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
418
src/server.rs
418
src/server.rs
|
|
@ -1,114 +1,153 @@
|
|||
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())
|
||||
}
|
||||
|
||||
let url_part = &request[9..]; // Remove "gemini://" prefix
|
||||
let slash_pos = url_part.find('/').unwrap_or(url_part.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 hostname = &url_part[..slash_pos];
|
||||
let slash_pos = url_part.find('/').unwrap_or(url_part.len());
|
||||
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<TcpStream>) -> io::Result<String> {
|
||||
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<TcpStream>,
|
||||
hosts: &std::collections::HashMap<String, crate::config::HostConfig>,
|
||||
max_concurrent_requests: usize,
|
||||
hosts: &HashMap<String, crate::config::HostConfig>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract hostname and path
|
||||
let (hostname, path) = match extract_hostname_and_path(&request) {
|
||||
Ok(result) => result,
|
||||
// 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!("Invalid URL format: {}", request);
|
||||
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
tracing::debug!("Unknown hostname: {}", truncate(&hostname));
|
||||
return send_response(&mut stream, "53 Proxy request refused\r\n").await;
|
||||
}
|
||||
};
|
||||
|
|
@ -116,68 +155,219 @@ pub async fn handle_connection(
|
|||
// 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;
|
||||
tokio::time::sleep(Duration::from_secs(_test_processing_delay)).await;
|
||||
}
|
||||
|
||||
// Resolve file path
|
||||
let file_path = match resolve_file_path(&path, &host_config.root) {
|
||||
Ok(path) => path,
|
||||
Ok(file_path) => file_path,
|
||||
Err(_) => {
|
||||
tracing::error!("Path resolution failed for: {}", path);
|
||||
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
|
||||
tracing::debug!("Path resolution failed for: {}", truncate(&path));
|
||||
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;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("Request timeout");
|
||||
let _ = send_response(&mut stream, "59 Bad Request\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<S>(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<S>(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?;
|
||||
|
||||
// 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"));
|
||||
// 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");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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<S>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
src/tls.rs
29
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<Vec<rustls::Certificate>> {
|
||||
pub fn load_certs(filename: &str) -> io::Result<Vec<CertificateDer<'static>>> {
|
||||
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<rustls::PrivateKey> {
|
||||
pub fn load_private_key(filename: &str) -> io::Result<PrivateKeyDer<'static>> {
|
||||
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(
|
||||
// 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",
|
||||
))
|
||||
"no supported private key found in file",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
46
tests/abort_connection.py
Normal file
46
tests/abort_connection.py
Normal file
|
|
@ -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 <hostname> [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()
|
||||
218
tests/connection_lifecycle.rs
Normal file
218
tests/connection_lifecycle.rs
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue