Skip to main content

rings_node/onion/
target.rs

1//! Target authority parsing shared by onion policy and proxy adapters.
2
3use std::net::IpAddr;
4#[cfg(any(test, rings_native))]
5use std::net::SocketAddr;
6
7#[cfg(rings_native)]
8use tokio::net::lookup_host;
9
10use crate::error::Error;
11use crate::error::Result;
12
13/// Closed parse failures for an onion proxy authority.
14#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
15pub enum OnionProxyTargetError {
16    /// Authority is empty after trimming.
17    #[error("onion proxy target authority must not be empty")]
18    EmptyAuthority,
19    /// A bracketed IPv6 host has no closing bracket.
20    #[error("invalid bracketed IPv6 onion proxy authority")]
21    MissingIpv6Bracket,
22    /// The authority has no explicit port separator or port value.
23    #[error("onion proxy authority must include a port")]
24    MissingPort,
25    /// Host is empty after canonicalization.
26    #[error("onion proxy target host must not be empty")]
27    EmptyHost,
28    /// Host contains whitespace.
29    #[error("onion proxy target host must not contain whitespace")]
30    HostWhitespace,
31    /// Port is not a valid `u16`.
32    #[error("onion proxy target has an invalid port")]
33    InvalidPort,
34    /// Port zero cannot identify a connect target.
35    #[error("onion proxy target port must be non-zero")]
36    ZeroPort,
37}
38
39/// Host/port target requested through an onion proxy.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct OnionProxyTarget {
42    host: String,
43    port: u16,
44}
45
46impl OnionProxyTarget {
47    /// Parse an HTTP CONNECT authority (`host:port` or `[ipv6]:port`).
48    pub fn parse_authority(authority: &str) -> Result<Self> {
49        let authority = authority.trim();
50        if authority.is_empty() {
51            return Err(OnionProxyTargetError::EmptyAuthority.into());
52        }
53
54        let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
55            let Some((host, rest)) = rest.split_once(']') else {
56                return Err(OnionProxyTargetError::MissingIpv6Bracket.into());
57            };
58            let Some(port) = rest.strip_prefix(':') else {
59                return Err(OnionProxyTargetError::MissingPort.into());
60            };
61            (host, port)
62        } else {
63            authority
64                .rsplit_once(':')
65                .ok_or(OnionProxyTargetError::MissingPort)?
66        };
67
68        let host = normalize_host(host)?;
69        let port = port
70            .parse::<u16>()
71            .map_err(|_| OnionProxyTargetError::InvalidPort)?;
72        if port == 0 {
73            return Err(OnionProxyTargetError::ZeroPort.into());
74        }
75
76        Ok(Self { host, port })
77    }
78
79    /// Return the normalized host.
80    pub fn host(&self) -> &str {
81        &self.host
82    }
83
84    /// Return the target port.
85    pub const fn port(&self) -> u16 {
86        self.port
87    }
88
89    /// Return the canonical authority string used for exit policy/service lookup.
90    pub fn authority(&self) -> String {
91        if self.host.contains(':') {
92            format!("[{}]:{}", self.host, self.port)
93        } else {
94            format!("{}:{}", self.host, self.port)
95        }
96    }
97}
98
99/// Resolve an exit target exactly once and retain only publicly routable
100/// addresses. Callers must connect to one of the returned socket addresses,
101/// rather than resolving the hostname again, so a DNS rebinding cannot change
102/// the destination after this admission decision.
103#[cfg(rings_native)]
104pub(crate) async fn resolve_public_target(target: &OnionProxyTarget) -> Result<Vec<SocketAddr>> {
105    let addresses = resolve_target_addresses(target).await?;
106    match select_public_exit_addresses(addresses) {
107        PublicAddressSelection::Public(addresses) => Ok(addresses),
108        PublicAddressSelection::Denied => Err(Error::NoPermission),
109        PublicAddressSelection::Empty => Err(Error::OnionTargetResolvedEmpty {
110            authority: target.authority(),
111        }),
112    }
113}
114
115/// Resolve one target into a single DNS snapshot without assigning egress authority.
116///
117/// Callers must pass the complete snapshot through a closed address-selection policy before using
118/// it. Keeping resolution separate lets an explicitly configured proxy recognize its synthetic
119/// DNS placeholders without treating ordinary private or loopback results as public.
120#[cfg(rings_native)]
121pub(crate) async fn resolve_target_addresses(target: &OnionProxyTarget) -> Result<Vec<SocketAddr>> {
122    Ok(lookup_host((target.host(), target.port()))
123        .await
124        .map_err(|error| Error::OnionTargetResolve {
125            authority: target.authority(),
126            source: error,
127        })?
128        .collect())
129}
130
131#[derive(Clone, Debug, Eq, PartialEq)]
132#[cfg(any(test, rings_native))]
133pub(crate) enum PublicAddressSelection {
134    Empty,
135    Denied,
136    Public(Vec<SocketAddr>),
137}
138
139/// Select a stable, de-duplicated public projection from one DNS result snapshot.
140#[cfg(any(test, rings_native))]
141pub(crate) fn select_public_exit_addresses(addresses: Vec<SocketAddr>) -> PublicAddressSelection {
142    if addresses.is_empty() {
143        return PublicAddressSelection::Empty;
144    }
145    let public = addresses
146        .into_iter()
147        .filter(|address| is_public_exit_ip(address.ip()))
148        .fold(Vec::new(), |mut selected, address| {
149            if !selected.contains(&address) {
150                selected.push(address);
151            }
152            selected
153        });
154    if public.is_empty() {
155        PublicAddressSelection::Denied
156    } else {
157        PublicAddressSelection::Public(public)
158    }
159}
160
161/// Browser exits cannot pin a hostname to the address admitted by this node.
162/// Only a public literal address is therefore safe in that environment.
163#[cfg(rings_browser)]
164pub(crate) fn validate_public_ip_literal(target: &OnionProxyTarget) -> Result<()> {
165    let address = target
166        .host()
167        .parse::<IpAddr>()
168        .map_err(|_| Error::NoPermission)?;
169    if is_public_exit_ip(address) {
170        Ok(())
171    } else {
172        Err(Error::NoPermission)
173    }
174}
175
176/// Return whether an address is eligible for arbitrary onion-exit egress.
177///
178/// This predicate is intentionally conservative: private, loopback, link-local,
179/// carrier-grade NAT, documentation, benchmarking, multicast, reserved,
180/// transition, and address-translation prefixes are all rejected. IPv4 values
181/// embedded in IPv6 are classified by their IPv4 address.
182const fn is_public_exit_ip(address: IpAddr) -> bool {
183    match address {
184        IpAddr::V4(address) => is_public_exit_ipv4(address.octets()),
185        IpAddr::V6(address) => {
186            let octets = address.octets();
187            if let Some(ipv4) = embedded_ipv4(octets) {
188                return is_public_exit_ipv4(ipv4);
189            }
190            // Public IPv6 unicast is currently allocated only from 2000::/3.
191            // Rejecting every other top-level block fails closed as IANA assigns
192            // new space, rather than treating reserved space as public egress.
193            if octets[0] < 0x20 || octets[0] > 0x3f {
194                return false;
195            }
196            !matches!(
197                octets,
198                // IETF protocol assignments, including Teredo and ORCHID.
199                [0x20, 0x01, 0x00..=0x01, ..]
200                    // Documentation 2001:db8::/32.
201                    | [0x20, 0x01, 0x0d, 0xb8, ..]
202                    // 6to4 can embed a private IPv4 destination.
203                    | [0x20, 0x02, ..]
204                    // Documentation 3fff::/20.
205                    | [0x3f, 0xf0..=0xff, ..]
206            )
207        }
208    }
209}
210
211const fn embedded_ipv4(octets: [u8; 16]) -> Option<[u8; 4]> {
212    let compatible_prefix = octets[0] == 0
213        && octets[1] == 0
214        && octets[2] == 0
215        && octets[3] == 0
216        && octets[4] == 0
217        && octets[5] == 0
218        && octets[6] == 0
219        && octets[7] == 0
220        && octets[8] == 0
221        && octets[9] == 0;
222    if compatible_prefix
223        && ((octets[10] == 0 && octets[11] == 0) || (octets[10] == 0xff && octets[11] == 0xff))
224    {
225        Some([octets[12], octets[13], octets[14], octets[15]])
226    } else {
227        None
228    }
229}
230
231const fn is_public_exit_ipv4([first, second, third, _fourth]: [u8; 4]) -> bool {
232    !matches!(
233        (first, second, third),
234        (0, _, _)
235            | (10, _, _)
236            | (100, 64..=127, _)
237            | (127, _, _)
238            | (169, 254, _)
239            | (172, 16..=31, _)
240            | (192, 0, 0)
241            | (192, 0, 2)
242            | (192, 88, 99)
243            | (192, 168, _)
244            | (198, 18..=19, _)
245            | (198, 51, 100)
246            | (203, 0, 113)
247            | (224..=255, _, _)
248    )
249}
250
251fn normalize_host(host: &str) -> Result<String> {
252    let host = host.trim().trim_end_matches('.');
253    if host.is_empty() {
254        return Err(OnionProxyTargetError::EmptyHost.into());
255    }
256    if host.chars().any(char::is_whitespace) {
257        return Err(OnionProxyTargetError::HostWhitespace.into());
258    }
259    Ok(host.to_ascii_lowercase())
260}
261
262#[cfg(test)]
263mod tests {
264    use std::net::IpAddr;
265    use std::net::SocketAddr;
266
267    use super::is_public_exit_ip;
268    #[cfg(rings_native)]
269    use super::resolve_public_target;
270    use super::select_public_exit_addresses;
271    #[cfg(rings_native)]
272    use super::OnionProxyTarget;
273    use super::PublicAddressSelection;
274    #[cfg(rings_native)]
275    use crate::error::Error;
276
277    #[test]
278    fn test_public_address_selection_distinguishes_empty_denied_and_deduplicated_public() {
279        let denied: SocketAddr = "127.0.0.1:443".parse().expect("denied address");
280        let public: SocketAddr = "8.8.8.8:443".parse().expect("public address");
281
282        assert_eq!(
283            select_public_exit_addresses(Vec::new()),
284            PublicAddressSelection::Empty
285        );
286        assert_eq!(
287            select_public_exit_addresses(vec![denied]),
288            PublicAddressSelection::Denied
289        );
290        assert_eq!(
291            select_public_exit_addresses(vec![denied, public, public]),
292            PublicAddressSelection::Public(vec![public])
293        );
294    }
295
296    #[test]
297    fn test_exit_address_predicate_rejects_internal_and_special_destinations() {
298        for address in [
299            "0.0.0.0",
300            "10.0.0.1",
301            "100.64.0.1",
302            "127.0.0.1",
303            "169.254.169.254",
304            "172.16.0.1",
305            "192.168.0.1",
306            "198.18.0.1",
307            "224.0.0.1",
308            "::",
309            "::1",
310            "::ffff:127.0.0.1",
311            "64:ff9b::7f00:1",
312            "2001:db8::1",
313            "2002:7f00:1::",
314            "3fff::1",
315            "4000::1",
316            "fc00::1",
317            "fe80::1",
318            "ff02::1",
319        ] {
320            let address = address.parse::<IpAddr>().expect("valid fixture address");
321            assert!(
322                !is_public_exit_ip(address),
323                "accepted special address {address}"
324            );
325        }
326    }
327
328    #[test]
329    fn test_exit_address_predicate_accepts_public_destinations() {
330        for address in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
331            let address = address.parse::<IpAddr>().expect("valid fixture address");
332            assert!(
333                is_public_exit_ip(address),
334                "rejected public address {address}"
335            );
336        }
337    }
338
339    #[cfg(rings_native)]
340    #[tokio::test]
341    async fn test_resolver_rejects_loopback_before_any_exit_connection() {
342        let target =
343            OnionProxyTarget::parse_authority("127.0.0.1:443").expect("valid loopback authority");
344
345        assert!(matches!(
346            resolve_public_target(&target).await,
347            Err(Error::NoPermission)
348        ));
349    }
350}