diff --git a/src/web/api.rs b/src/web/api.rs index c8eea7ea..2875f2aa 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -21,6 +21,7 @@ use crate::{ ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage, WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION, }, + origin::canonical, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack}, }, web_api::{ @@ -369,7 +370,11 @@ impl ApiService { // trusted, and reachability is bounded by that hop's own authentication // (e.g. SSH keys or Tailscale ACLs). This gate only stops direct, // unauthenticated peers. - if !self.remote_addr.ip().is_loopback() { + // + // An IPv4 client reaching a dual-stack (`::`) bind appears as an + // IPv4-mapped IPv6 peer (`::ffff:127.0.0.1`), so canonicalize to the bare + // IPv4 form before the loopback test, matching `origin`'s handling. + if !canonical(self.remote_addr.ip()).is_loopback() { return msgpack( ErrorResponse::forbidden("/api/open is only available to local clients"), StatusCode::FORBIDDEN, diff --git a/src/web/origin.rs b/src/web/origin.rs index 49283ca1..bb36a7e7 100644 --- a/src/web/origin.rs +++ b/src/web/origin.rs @@ -139,8 +139,9 @@ pub fn allowed_hosts(bind: IpAddr, port: u16, extra: &[String]) -> Option IpAddr { +/// so it classifies and compares consistently with the bare IPv4 address. Shared +/// with the `/api/open` peer check so it recognizes a mapped loopback peer too. +pub(crate) fn canonical(ip: IpAddr) -> IpAddr { match ip { IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), v4 => v4, @@ -474,6 +475,16 @@ mod test { assert!(check_request_origin(&request, allowed.as_ref()).is_none()); } + #[test] + fn canonical_collapses_ipv4_mapped_loopback() { + // The `/api/open` peer gate relies on this: an IPv4 loopback client + // reaching a dual-stack (`::`) bind arrives as `::ffff:127.0.0.1`, which + // is only recognized as loopback after canonicalization. + let mapped: IpAddr = "::ffff:127.0.0.1".parse().unwrap(); + assert!(!mapped.is_loopback()); + assert!(canonical(mapped).is_loopback()); + } + #[test] fn normalize_host_strips_brackets_and_zone_id() { // Brackets and an IPv6 zone id must be removed so the result parses as an