Expose canonical for reuse in the /api/open check

This commit is contained in:
boatbomber 2026-06-03 14:01:10 -07:00
parent 32cc041e96
commit 7db1d04562
2 changed files with 19 additions and 3 deletions

View file

@ -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,

View file

@ -139,8 +139,9 @@ pub fn allowed_hosts(bind: IpAddr, port: u16, extra: &[String]) -> Option<Allowe
}
/// Collapses an IPv4-mapped IPv6 address (`::ffff:192.168.0.1`) to its IPv4 form
/// so it classifies and compares consistently with the bare IPv4 address.
fn canonical(ip: IpAddr) -> 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