harden: relay timeouts, DNS resolver reuse, cache hygiene + frontend polish
Server (relay/SSRF): - Bound connect+TLS+request under CONNECT_TIMEOUT and body download under BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely - Add inbound header_read_timeout to defeat slow-loris connections - Allowlist relayed Content-Type (fall back to octet-stream) to block content-confusion from attacker-controlled stream origins - Reuse one DNS resolver process-wide instead of rebuilding it per request (restores hickory's DNS cache; drops per-segment resolver setup) - Probe cache: random per-process hash seed (DefaultHasher was deterministic) + bounded growth with expired-entry eviction - Real Ctrl-C handler for a clean shutdown (honours the banner) - Collapse the wrap_raw no-op alias into wrap Frontend: - Guide: visible focus ring tracking arrow-key navigation - Player: buffering/tuning indicator while <video> is stalled - prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA) - Service worker: date-stamped cache names so shell updates propagate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+56
-20
@@ -7,9 +7,10 @@
|
||||
mod relay;
|
||||
mod ssrf;
|
||||
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::hash::{BuildHasher, Hash, Hasher};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -36,6 +37,12 @@ use relay::{
|
||||
const MAX_INFLIGHT_RELAY: usize = 32;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
const PROBE_TTL: Duration = Duration::from_secs(300);
|
||||
/// Bound how long a client may take to send its request headers — defeats
|
||||
/// slow-loris connections that would otherwise hold a Tokio task open forever.
|
||||
const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Hard ceiling on cached probe entries; past this we evict expired ones (and,
|
||||
/// if still full, clear) so the cache can't grow without bound over a long run.
|
||||
const PROBE_CACHE_MAX: usize = 4096;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Metrics {
|
||||
@@ -57,6 +64,10 @@ struct AppState {
|
||||
metrics: Metrics,
|
||||
relay_gate: Semaphore,
|
||||
probe_cache: Mutex<HashMap<u64, ProbeCacheEntry>>,
|
||||
// Random per-process seed for the probe-cache key. DefaultHasher is
|
||||
// deterministic across runs, so an attacker could engineer URL collisions;
|
||||
// RandomState reseeds every boot, making the key unpredictable.
|
||||
cache_hasher: RandomState,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -75,28 +86,44 @@ async fn main() {
|
||||
metrics: Metrics::default(),
|
||||
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
|
||||
probe_cache: Mutex::new(HashMap::new()),
|
||||
cache_hasher: RandomState::new(),
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind(bind).await.expect("bind");
|
||||
eprintln!("The Pondering Orb is gazing on http://{bind} (Ctrl-C to close the eye)");
|
||||
|
||||
loop {
|
||||
let (stream, _peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log_line("accept_error", &e.to_string(), None, None);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let io = TokioIo::new(stream);
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req| handle(st.clone(), req));
|
||||
if let Err(e) = http1::Builder::new().serve_connection(io, svc).await {
|
||||
// client disconnects are noise; log at debug granularity only
|
||||
let _ = e;
|
||||
}
|
||||
});
|
||||
let serve = async {
|
||||
loop {
|
||||
let (stream, _peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log_line("accept_error", &e.to_string(), None, None);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let io = TokioIo::new(stream);
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req| handle(st.clone(), req));
|
||||
let conn = http1::Builder::new()
|
||||
.header_read_timeout(HEADER_READ_TIMEOUT)
|
||||
.serve_connection(io, svc);
|
||||
if let Err(e) = conn.await {
|
||||
// client disconnects are noise; log at debug granularity only
|
||||
let _ = e;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Honour the banner: a real Ctrl-C handler exits cleanly instead of relying
|
||||
// on the default SIGINT kill (and keeps tokio's `signal` feature in use).
|
||||
tokio::select! {
|
||||
_ = serve => {}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
eprintln!("\nThe eye closes.");
|
||||
log_line("shutdown", "ctrl-c", None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +244,9 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
|
||||
_ => return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}"),
|
||||
};
|
||||
|
||||
// hash the URL for the cache key (never store the raw URL — token hygiene)
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
// hash the URL for the cache key (never store the raw URL — token hygiene).
|
||||
// Random per-process seed → keys aren't attacker-predictable across runs.
|
||||
let mut hasher = state.cache_hasher.build_hasher();
|
||||
raw.hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
|
||||
@@ -244,6 +272,14 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
|
||||
state.metrics.probe_dead.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if let Ok(mut cache) = state.probe_cache.lock() {
|
||||
// Bound growth: at the ceiling, drop expired entries first; if that
|
||||
// doesn't free space (all entries still fresh), clear and start over.
|
||||
if cache.len() >= PROBE_CACHE_MAX {
|
||||
cache.retain(|_, (ts, ..)| ts.elapsed() < PROBE_TTL);
|
||||
if cache.len() >= PROBE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
cache.insert(key, (Instant::now(), outcome.ok, outcome.status, outcome.latency_ms));
|
||||
}
|
||||
log_line("probe", if outcome.ok { "live" } else { "dead" }, parsed.host_str(), Some(outcome.latency_ms));
|
||||
|
||||
+83
-41
@@ -25,6 +25,12 @@ const MAX_MANIFEST: usize = 10 * 1024 * 1024; // 10 MB
|
||||
const MAX_SEGMENT: usize = 64 * 1024 * 1024; // 64 MB
|
||||
const MAX_REDIRECTS: u32 = 4;
|
||||
const UA: &str = "PonderingOrb/0.1 (+hardened-relay)";
|
||||
/// Bound connect + TLS handshake + request → response-headers. A stalling
|
||||
/// upstream must never pin one of the relay's permits open indefinitely.
|
||||
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
/// Bound the body download too — the size cap stops a *large* body, but a
|
||||
/// slow-drip body would otherwise hold the permit far longer than that implies.
|
||||
const BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RelayError {
|
||||
@@ -36,6 +42,7 @@ pub enum RelayError {
|
||||
Http(String),
|
||||
TooLarge,
|
||||
TooManyRedirects,
|
||||
Timeout,
|
||||
Upstream(StatusCode),
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ impl std::fmt::Display for RelayError {
|
||||
RelayError::Http(e) => write!(f, "http: {e}"),
|
||||
RelayError::TooLarge => write!(f, "response exceeds size cap"),
|
||||
RelayError::TooManyRedirects => write!(f, "too many redirects"),
|
||||
RelayError::Timeout => write!(f, "upstream timed out"),
|
||||
RelayError::Upstream(s) => write!(f, "upstream status {s}"),
|
||||
}
|
||||
}
|
||||
@@ -105,43 +113,49 @@ async fn fetch_once(
|
||||
.await
|
||||
.map_err(RelayError::Ssrf)?;
|
||||
|
||||
let tcp = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| RelayError::Connect(e.to_string()))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
// Bound connect → TLS → request → response-headers as one unit. A stalling
|
||||
// upstream cannot hold one of the 32 relay permits open past CONNECT_TIMEOUT.
|
||||
let resp = tokio::time::timeout(CONNECT_TIMEOUT, async {
|
||||
let tcp = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| RelayError::Connect(e.to_string()))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
|
||||
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
|
||||
let tls = connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| RelayError::Tls(e.to_string()))?;
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
|
||||
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
|
||||
let tls = connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| RelayError::Tls(e.to_string()))?;
|
||||
|
||||
let io = TokioIo::new(tls);
|
||||
let (mut sender, conn) = http1::handshake(io)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
let io = TokioIo::new(tls);
|
||||
let (mut sender, conn) = http1::handshake(io)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
|
||||
let mut pq = url.path().to_string();
|
||||
if let Some(q) = url.query() {
|
||||
pq.push('?');
|
||||
pq.push_str(q);
|
||||
}
|
||||
let req = Request::builder()
|
||||
.uri(pq)
|
||||
.header(hyper::header::HOST, host.as_str())
|
||||
.header(hyper::header::USER_AGENT, UA)
|
||||
.header(hyper::header::ACCEPT, "*/*")
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
let mut pq = url.path().to_string();
|
||||
if let Some(q) = url.query() {
|
||||
pq.push('?');
|
||||
pq.push_str(q);
|
||||
}
|
||||
let req = Request::builder()
|
||||
.uri(pq)
|
||||
.header(hyper::header::HOST, host.as_str())
|
||||
.header(hyper::header::USER_AGENT, UA)
|
||||
.header(hyper::header::ACCEPT, "*/*")
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| RelayError::Timeout)??;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
@@ -171,18 +185,48 @@ async fn fetch_once(
|
||||
}
|
||||
|
||||
let limited = Limited::new(resp.into_body(), max_bytes);
|
||||
let collected = limited.collect().await.map_err(|_| RelayError::TooLarge)?;
|
||||
let collected = tokio::time::timeout(BODY_TIMEOUT, limited.collect())
|
||||
.await
|
||||
.map_err(|_| RelayError::Timeout)?
|
||||
.map_err(|_| RelayError::TooLarge)?;
|
||||
let body = collected.to_bytes();
|
||||
|
||||
Ok(Upstream {
|
||||
status,
|
||||
content_type,
|
||||
content_type: sanitize_content_type(&content_type),
|
||||
body,
|
||||
final_url: url.clone(),
|
||||
redirects: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clamp the upstream-declared Content-Type to a known-safe media allowlist.
|
||||
/// An attacker-controlled stream origin could otherwise label segment bytes as
|
||||
/// `text/html` or `application/javascript`; if a tab ever navigates straight to
|
||||
/// a /relay URL, that declared type (not just sniffing, which nosniff blocks)
|
||||
/// would govern interpretation. Anything off-list collapses to octet-stream.
|
||||
fn sanitize_content_type(ct: &str) -> String {
|
||||
// Compare on the bare type, ignoring any `; charset=`/`; codecs=` parameters.
|
||||
let base = ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||
const ALLOWED: &[&str] = &[
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegurl",
|
||||
"audio/mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"video/mp2t",
|
||||
"video/mp4",
|
||||
"video/iso.segment",
|
||||
"audio/aac",
|
||||
"audio/mp4",
|
||||
"application/octet-stream",
|
||||
];
|
||||
if ALLOWED.contains(&base.as_str()) {
|
||||
ct.to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry: validated GET with bounded, re-validated redirect following.
|
||||
pub async fn fetch_validated(
|
||||
connector: &TlsConnector,
|
||||
@@ -350,7 +394,7 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
|
||||
Ok(abs) => {
|
||||
let mut s = String::with_capacity(line.len() + 40);
|
||||
s.push_str(&line[..uri_start]);
|
||||
s.push_str(&wrap_raw(&abs));
|
||||
s.push_str(&wrap(&abs));
|
||||
s.push_str(&line[end..]);
|
||||
s
|
||||
}
|
||||
@@ -358,14 +402,12 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// "/relay?url=<pct-encoded absolute>"
|
||||
/// "/relay?url=<pct-encoded absolute>". The percent-encoding leaves only
|
||||
/// RFC3986-unreserved bytes, so the result is safe both on a bare URI line and
|
||||
/// inside an already-quoted `URI="..."` tag attribute (no `"` can appear).
|
||||
fn wrap(abs: &Url) -> String {
|
||||
format!("/relay?url={}", pct(abs.as_str()))
|
||||
}
|
||||
fn wrap_raw(abs: &Url) -> String {
|
||||
// for inside an already-quoted attribute
|
||||
wrap(abs)
|
||||
}
|
||||
|
||||
/// Minimal RFC3986 component percent-encoding (encode everything non-unreserved).
|
||||
fn pct(s: &str) -> String {
|
||||
|
||||
+14
-5
@@ -7,10 +7,23 @@
|
||||
//! between check and connect cannot smuggle us onto an internal address.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
|
||||
/// Built once on first use and reused for the process lifetime. Rebuilding the
|
||||
/// resolver per request re-read /etc/resolv.conf and — worse — discarded
|
||||
/// hickory's in-memory DNS cache, forcing a fresh lookup for every HLS segment.
|
||||
static RESOLVER: OnceLock<TokioAsyncResolver> = OnceLock::new();
|
||||
|
||||
fn resolver() -> &'static TokioAsyncResolver {
|
||||
RESOLVER.get_or_init(|| {
|
||||
TokioAsyncResolver::tokio_from_system_conf()
|
||||
.unwrap_or_else(|_| TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SsrfError {
|
||||
Resolve(String),
|
||||
@@ -78,11 +91,7 @@ pub async fn resolve_and_validate(host: &str, port: u16) -> Result<SocketAddr, S
|
||||
return Ok(SocketAddr::new(ip, port));
|
||||
}
|
||||
|
||||
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
|
||||
Ok(r) => r,
|
||||
Err(_) => TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
|
||||
};
|
||||
let lookup = resolver
|
||||
let lookup = resolver()
|
||||
.lookup_ip(host)
|
||||
.await
|
||||
.map_err(|e| SsrfError::Resolve(e.to_string()))?;
|
||||
|
||||
Reference in New Issue
Block a user