From 2d6dac4a2fb2d46ca9c901c3770b7f99f3e1dbfe Mon Sep 17 00:00:00 2001 From: Johann150 Date: Fri, 3 Apr 2026 18:31:25 +0200 Subject: [PATCH] Allow IP addresses in URLs Ref: https://github.com/mbrubeck/agate/pull/433 Co-authored-by: oooo-ps --- CHANGELOG.md | 6 ++++ README.md | 2 ++ src/main.rs | 84 +++++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a265c0..2d55e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,16 @@ Updates to dependencies are not considered notable changes for the purpose of th This may lead to no listed changes for a version. ## [Unreleased] +Thank you to @oooo-ps for contributing to this release. ### Fixed * Use the default port when checking for the right port. +### Added +* Accept requests with URLs containing IP addresses. + Agate will check that the IP matches the local address for TCP sockets. + Unix sockets will accept any IP address. + ## [3.3.19] - 2025-09-18 ### Fixed diff --git a/README.md b/README.md index df18001..72bc438 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ If you want to serve the same content for multiple domains, you can instead disa When one or more `--hostname`s are specified, Agate will check that the hostnames and port in request URLs match the specified hostnames and the listening ports. If Agate is behind a proxy on another port and receives a request with an URL specifying the proxy port, this port may not match one of Agate's listening ports and the request will be rejected: it is possible to disable the port check with `--skip-port-check`. +If Agate receives a request using an IP address in the URL, it will check that the IP address from the URL matches the local IP address of the TCP connection. Because Unix sockets do not have an IP address, this check cannot be performed and any IP address will be permitted via Unix sockets. + ### Certificates Agate has support for using multiple certificates with the `--certs` option. Agate will thus always require that a client uses SNI, which should not be a problem since the Gemini specification also requires SNI to be used. diff --git a/src/main.rs b/src/main.rs index dea16c4..2f7f1e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -496,9 +496,47 @@ impl RequestHandle { } } +trait CheckHost { + fn check_host(&self, host: &url::Host) -> bool; +} + +fn check_domain(domain: &str) -> bool { + if ARGS.hostnames.is_empty() { + // no hostnames -> hostname check disabled + true + } else { + ARGS.hostnames.iter().any(|x| x == &Host::Domain(domain)) + } +} + +impl CheckHost for TcpStream { + fn check_host(&self, host: &url::Host) -> bool { + match host { + url::Host::Ipv4(ip) => self + .local_addr() + .is_ok_and(|local| &local.ip().to_canonical() == ip), + url::Host::Ipv6(ip) => self.local_addr().is_ok_and(|local| match local.ip() { + IpAddr::V4(local) => &local.to_ipv6_mapped() == ip, + IpAddr::V6(local) => &local == ip, + }), + url::Host::Domain(domain) => check_domain(domain), + } + } +} + +#[cfg(unix)] +impl CheckHost for UnixStream { + fn check_host(&self, host: &url::Host) -> bool { + match host { + url::Host::Ipv4(..) | url::Host::Ipv6(..) => true, + url::Host::Domain(domain) => check_domain(domain), + } + } +} + impl RequestHandle where - T: AsyncWriteExt + AsyncReadExt + Unpin, + T: AsyncWriteExt + AsyncReadExt + Unpin + CheckHost, { /// Do the necessary actions to handle this request. Returns a corresponding /// log line as Err or Ok, depending on if the request finished with or @@ -570,24 +608,34 @@ where return Err((BAD_REQUEST, "URL contains fragment or userinfo")); } - // correct host - let Some(domain) = url.domain() else { - return Err((BAD_REQUEST, "URL does not contain a domain")); + // normalize host + let host = match url.host() { + Some(Host::Domain(domain)) => { + // because the gemini scheme is not special enough for WHATWG, + // (re-)normalize it properly + let domain = Host::parse( + &percent_decode_str(domain) + .decode_utf8() + .or(Err((BAD_REQUEST, "Invalid URL")))?, + ) + .or(Err((BAD_REQUEST, "Invalid URL")))?; + // also put the now properly normalized host back into the url + // TODO: simplify when resolved + url.set_host(Some(&domain.to_string())) + .expect("invalid domain?"); + + domain + } + // these match arms are needed for "converting" from Host<&str> to Host + Some(Host::Ipv4(ip)) => Host::Ipv4(ip), + Some(Host::Ipv6(ip)) => Host::Ipv6(ip), + None => { + // cannot-be-a-base URLs cannot be used here + return Err((BAD_REQUEST, "URL does not contain a domain")); + } }; - // because the gemini scheme is not special enough for WHATWG, normalize - // it ourselves - let host = Host::parse( - &percent_decode_str(domain) - .decode_utf8() - .or(Err((BAD_REQUEST, "Invalid URL")))?, - ) - .or(Err((BAD_REQUEST, "Invalid URL")))?; - // TODO: simplify when resolved - url.set_host(Some(&host.to_string())) - .expect("invalid domain?"); - // do not use "contains" here since it requires the same type and does - // not allow to check for Host<&str> if the vec contains Hostname - if !ARGS.hostnames.is_empty() && !ARGS.hostnames.iter().any(|h| h == &host) { + // check for correct host + if !self.stream.get_ref().0.check_host(&host) { return Err((PROXY_REQUEST_REFUSED, "Proxy request refused")); }