mirror of https://github.com/ospab/ostp.git
fix(server): rate-limit the (currently unwired) open UDP DNS listener
DnsServer::run_local_udp_listener binds 0.0.0.0 and answers every UDP datagram by resolving it and replying to the packet's (unverified, spoofable) source address - a textbook DNS reflection/amplification primitive. An attacker spoofing a victim's IP as the query source turns any server with this listener running into a free amplifier against that victim, with zero authentication gating it (unlike the main OSTP port, there's no Noise handshake here). Nothing in the codebase currently calls this function - the live DNS path is router.route_dns(), reached only through the authenticated OSTP tunnel relay (relay.rs). But the doc comment describes this as an intended, not-yet-wired entry point for clients that point their OS resolver directly at the server, so it's a real latent risk for whoever connects it without realizing the implication. Added a global (not per-source-IP - per-IP limiting doesn't help against a reflection attack, since the attacker never sees the replies and can spread queries across arbitrary spoofed sources) token bucket capping total replies/sec, so connecting this later can't silently reintroduce unbounded amplification.
This commit is contained in:
parent
f01ed4ec25
commit
51b947e6ff
|
|
@ -276,6 +276,18 @@ impl DnsServer {
|
|||
///
|
||||
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
|
||||
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
|
||||
///
|
||||
/// SECURITY: this socket is bound on 0.0.0.0, reachable directly from the
|
||||
/// public internet with no authentication (unlike the main OSTP port,
|
||||
/// there is no Noise handshake gating it). Answering every UDP datagram
|
||||
/// by resolving and replying to its (unverified, spoofable) source
|
||||
/// address is a textbook DNS reflection/amplification primitive: an
|
||||
/// attacker spoofing a victim's IP as the query source turns this server
|
||||
/// into a free amplifier against that victim. There is currently no
|
||||
/// caller for this function anywhere in the codebase, but the rate
|
||||
/// limiter below exists so that connecting it later doesn't silently
|
||||
/// reintroduce that risk - it bounds how much amplification bandwidth
|
||||
/// this listener can ever contribute, regardless of query volume.
|
||||
pub async fn run_local_udp_listener(self: Arc<Self>) {
|
||||
let port = self.config.read().await.local_port;
|
||||
let bind_addr = format!("0.0.0.0:{port}");
|
||||
|
|
@ -289,10 +301,30 @@ impl DnsServer {
|
|||
};
|
||||
tracing::info!("Built-in DNS server listening on UDP {bind_addr}");
|
||||
|
||||
// Global token bucket capping total replies/sec this listener will
|
||||
// ever send. Deliberately global (not per-source-IP): per-IP limiting
|
||||
// does nothing against a reflection attack, since the attacker never
|
||||
// sees the responses and can spread queries across arbitrarily many
|
||||
// spoofed sources anyway. A global cap bounds this server's total
|
||||
// contribution to any attack regardless of how the queries are
|
||||
// distributed.
|
||||
const MAX_REPLIES_PER_SEC: f64 = 100.0;
|
||||
let mut tokens: f64 = MAX_REPLIES_PER_SEC;
|
||||
let mut last_refill = tokio::time::Instant::now();
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
match socket.recv_from(&mut buf).await {
|
||||
Ok((n, peer)) => {
|
||||
let now = tokio::time::Instant::now();
|
||||
tokens = (tokens + now.duration_since(last_refill).as_secs_f64() * MAX_REPLIES_PER_SEC)
|
||||
.min(MAX_REPLIES_PER_SEC);
|
||||
last_refill = now;
|
||||
if tokens < 1.0 {
|
||||
continue; // over budget: drop silently, no reply sent
|
||||
}
|
||||
tokens -= 1.0;
|
||||
|
||||
let query = buf[..n].to_vec();
|
||||
let srv = self.clone();
|
||||
let sock = socket.clone();
|
||||
|
|
|
|||
Loading…
Reference in New Issue