nexo_pairing/
url_resolver.rs1use std::net::IpAddr;
22
23#[derive(Debug, Clone)]
24pub struct UrlInputs {
25 pub public_url: Option<String>,
26 pub tunnel_url: Option<String>,
27 pub gateway_remote_url: Option<String>,
28 pub lan_url: Option<String>,
29 pub ws_cleartext_allow_extra: Vec<String>,
31}
32
33#[derive(Debug, Clone)]
34pub struct ResolvedUrl {
35 pub url: String,
36 pub source: &'static str,
37}
38
39#[derive(Debug, Clone, thiserror::Error)]
40pub enum ResolveError {
41 #[error("gateway only bound to loopback; set pairing.public_url, enable tunnel, or use gateway.bind=lan")]
42 LoopbackOnly,
43 #[error("resolved url '{url}' uses ws:// but host is not in the cleartext-allow list (loopback / RFC1918 / link-local / .local / 10.0.2.2 / extras)")]
44 InsecureCleartext { url: String },
45 #[error("invalid url: {0}")]
46 Invalid(String),
47}
48
49pub fn resolve(inputs: &UrlInputs) -> Result<ResolvedUrl, ResolveError> {
50 let candidate = pick_candidate(inputs)?;
51 enforce_security(&candidate.url, &inputs.ws_cleartext_allow_extra)?;
52 Ok(candidate)
53}
54
55fn pick_candidate(inputs: &UrlInputs) -> Result<ResolvedUrl, ResolveError> {
56 if let Some(u) = inputs.public_url.as_ref().filter(|s| !s.trim().is_empty()) {
57 return Ok(ResolvedUrl {
58 url: u.trim().to_string(),
59 source: "pairing.public_url",
60 });
61 }
62 if let Some(u) = inputs.tunnel_url.as_ref().filter(|s| !s.trim().is_empty()) {
63 return Ok(ResolvedUrl {
64 url: u.trim().to_string(),
65 source: "tunnel.url",
66 });
67 }
68 if let Some(u) = inputs
69 .gateway_remote_url
70 .as_ref()
71 .filter(|s| !s.trim().is_empty())
72 {
73 return Ok(ResolvedUrl {
74 url: u.trim().to_string(),
75 source: "gateway.remote.url",
76 });
77 }
78 if let Some(u) = inputs.lan_url.as_ref().filter(|s| !s.trim().is_empty()) {
79 return Ok(ResolvedUrl {
80 url: u.trim().to_string(),
81 source: "gateway.bind=lan",
82 });
83 }
84 Err(ResolveError::LoopbackOnly)
85}
86
87fn enforce_security(url: &str, extras: &[String]) -> Result<(), ResolveError> {
88 let scheme = url
89 .split("://")
90 .next()
91 .ok_or_else(|| ResolveError::Invalid(url.into()))?
92 .to_ascii_lowercase();
93 if scheme == "wss" || scheme == "https" {
94 return Ok(());
95 }
96 if scheme != "ws" && scheme != "http" {
97 return Err(ResolveError::Invalid(format!(
98 "unsupported scheme: {scheme}"
99 )));
100 }
101 let after = url.split("://").nth(1).unwrap_or("");
102 let host = after
103 .split('/')
104 .next()
105 .unwrap_or("")
106 .split(':')
107 .next()
108 .unwrap_or("");
109 if host.is_empty() {
110 return Err(ResolveError::Invalid(url.into()));
111 }
112 if is_cleartext_allowed(host, extras) {
113 return Ok(());
114 }
115 Err(ResolveError::InsecureCleartext { url: url.into() })
116}
117
118fn is_cleartext_allowed(host: &str, extras: &[String]) -> bool {
119 if extras.iter().any(|h| h.eq_ignore_ascii_case(host)) {
120 return true;
121 }
122 if host.eq_ignore_ascii_case("localhost") || host == "10.0.2.2" {
123 return true;
124 }
125 if host.to_ascii_lowercase().ends_with(".local") {
126 return true;
127 }
128 match host.parse::<IpAddr>() {
129 Ok(IpAddr::V4(v4)) => {
130 if v4.is_loopback() || v4.is_link_local() || v4.is_private() {
131 return true;
132 }
133 false
134 }
135 Ok(IpAddr::V6(v6)) => {
136 let segs = v6.segments();
139 v6.is_loopback() || (segs[0] & 0xffc0) == 0xfe80
140 }
141 Err(_) => false,
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn empty() -> UrlInputs {
150 UrlInputs {
151 public_url: None,
152 tunnel_url: None,
153 gateway_remote_url: None,
154 lan_url: None,
155 ws_cleartext_allow_extra: vec![],
156 }
157 }
158
159 #[test]
160 fn loopback_only_fails_closed() {
161 let err = resolve(&empty()).unwrap_err();
162 assert!(matches!(err, ResolveError::LoopbackOnly));
163 }
164
165 #[test]
166 fn priority_public_over_tunnel() {
167 let mut i = empty();
168 i.public_url = Some("wss://op.example.com".into());
169 i.tunnel_url = Some("wss://abc.ngrok.app".into());
170 let r = resolve(&i).unwrap();
171 assert_eq!(r.source, "pairing.public_url");
172 assert_eq!(r.url, "wss://op.example.com");
173 }
174
175 #[test]
176 fn priority_tunnel_over_remote() {
177 let mut i = empty();
178 i.tunnel_url = Some("wss://abc.ngrok.app".into());
179 i.gateway_remote_url = Some("wss://legacy".into());
180 let r = resolve(&i).unwrap();
181 assert_eq!(r.source, "tunnel.url");
182 }
183
184 #[test]
185 fn lan_ws_allowed_for_rfc1918() {
186 let mut i = empty();
187 i.lan_url = Some("ws://192.168.1.10:9090".into());
188 let r = resolve(&i).unwrap();
189 assert_eq!(r.source, "gateway.bind=lan");
190 }
191
192 #[test]
193 fn ws_blocked_on_public_host() {
194 let mut i = empty();
195 i.public_url = Some("ws://api.example.com".into());
196 let err = resolve(&i).unwrap_err();
197 assert!(matches!(err, ResolveError::InsecureCleartext { .. }));
198 }
199
200 #[test]
201 fn ws_allowed_on_localhost() {
202 let mut i = empty();
203 i.public_url = Some("ws://localhost:9090".into());
204 resolve(&i).unwrap();
205 }
206
207 #[test]
208 fn ws_allowed_on_dot_local_mdns() {
209 let mut i = empty();
210 i.public_url = Some("ws://kitchen-pi.local:9090".into());
211 resolve(&i).unwrap();
212 }
213
214 #[test]
215 fn ws_allowed_on_extras() {
216 let mut i = empty();
217 i.public_url = Some("ws://my.cool.host:9090".into());
218 i.ws_cleartext_allow_extra = vec!["my.cool.host".into()];
219 resolve(&i).unwrap();
220 }
221
222 #[test]
223 fn ws_allowed_on_android_emu() {
224 let mut i = empty();
225 i.public_url = Some("ws://10.0.2.2:9090".into());
226 resolve(&i).unwrap();
227 }
228
229 #[test]
230 fn link_local_v4_allowed() {
231 let mut i = empty();
232 i.lan_url = Some("ws://169.254.1.5:9090".into());
233 resolve(&i).unwrap();
234 }
235}