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:
Jeena 2026-07-30 04:30:38 +00:00
parent 7de660dbb6
commit 9ca54e98c9
16 changed files with 1052 additions and 338 deletions

View file

@ -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
```