Server: no built-in public host, no trusted proxy by default; OpenAPI server variable host (D-123)

KVITT_PUBLIC_URL is required: every command refuses to start without it (compose
always sets it). KVITT_TRUSTED_PROXY defaults to none, which the rate limiter never
resolves. contracts/openapi.yaml and the utoipa document: servers[].url
https://{host}:8443 with variable host (default kvitt.example.invalid). E-7 and E-12
examples on kvitt.example.invalid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-19 20:11:33 +02:00
parent 4b1ae0c473
commit c9abd24c77
13 changed files with 168 additions and 39 deletions
@@ -20,8 +20,9 @@
//! on restart; there is no table.
//!
//! The client IP is the TCP peer (axum `ConnectInfo<SocketAddr>`), or the last `X-Forwarded-For` entry when the peer
//! is an address of `KVITT_TRUSTED_PROXY` (Caddy, SPEC §8.5). The host name is resolved lazily and re-resolved at most
//! once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted (D-022). Without
//! is an address of `KVITT_TRUSTED_PROXY` (Nginx Proxy Manager, SPEC §8.5). The host name is resolved lazily and
//! re-resolved at most once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted
//! (D-022), and so does the default `none` ([`NO_TRUSTED_PROXY`]), which is never looked up. Without
//! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket.
use std::collections::HashMap;
@@ -39,6 +40,7 @@ use tower::{Layer, Service};
use crate::audit::BoxFuture;
use crate::auth::{bearer_token, is_token_shaped, sha256_hex};
use crate::config::NO_TRUSTED_PROXY;
use crate::error::AppError;
use crate::time::Clock;
use crate::{AppState, lock};
@@ -214,7 +216,7 @@ impl Limiter {
return addrs.clone();
}
let name = self.trusted_proxy.trim();
let addrs: Vec<IpAddr> = if name.is_empty() {
let addrs: Vec<IpAddr> = if name.is_empty() || name.eq_ignore_ascii_case(NO_TRUSTED_PROXY) {
Vec::new()
} else if let Ok(ip) = name.parse::<IpAddr>() {
vec![ip]
@@ -463,6 +465,46 @@ mod tests {
);
}
fn limiter_trusting(trusted_proxy: &str) -> Limiter {
let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap();
Limiter::new(Arc::new(FixedClock::new(now)), trusted_proxy.to_owned())
}
#[tokio::test]
async fn the_default_none_trusts_no_peer() {
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
for value in [crate::config::DEFAULT_TRUSTED_PROXY, " NONE ", ""] {
let limiter = limiter_trusting(value);
assert_eq!(
limiter.proxy_addrs().await,
Vec::<IpAddr>::new(),
"{value:?}"
);
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
}
}
#[tokio::test]
async fn an_unresolvable_name_trusts_no_peer() {
// `.invalid` never resolves (RFC 6761).
let limiter = limiter_trusting("proxy.kvitt.invalid");
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
}
#[tokio::test]
async fn an_ip_literal_is_trusted() {
let limiter = limiter_trusting("192.168.1.20");
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
assert_eq!(
limiter.client_ip(Some(peer), Some(forwarded)).await,
forwarded
);
}
fn token_key(i: u32) -> Key {
Key::Token {
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),