1use std::collections::BTreeMap;
16use std::net::{IpAddr, SocketAddr};
17use std::time::{Duration, Instant};
18
19use base64::Engine as _;
20use serde::{Deserialize, Serialize};
21use url::Url;
22
23use super::registry_probe::ip_is_blocked;
24use super::widget_access::AccessError;
25
26pub const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
27pub const MAX_REQUEST_BYTES: usize = 10 * 1024 * 1024;
28const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
29const MAX_TIMEOUT: Duration = Duration::from_secs(120);
30const MAX_REDIRECTS: usize = 5;
31const DNS_TIMEOUT: Duration = Duration::from_secs(5);
32
33const REFUSED_HEADERS: &[&str] = &[
36 "host",
37 "connection",
38 "content-length",
39 "transfer-encoding",
40 "te",
41 "trailer",
42 "upgrade",
43 "keep-alive",
44 "expect",
45];
46
47const CREDENTIAL_HEADERS: &[&str] = &["authorization", "cookie"];
49
50#[derive(Debug, Clone, Default, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct FetchRequest {
53 pub url: String,
54 #[serde(default)]
55 pub method: Option<String>,
56 #[serde(default)]
57 pub headers: BTreeMap<String, String>,
58 #[serde(default)]
59 pub body: Option<String>,
60 #[serde(default)]
61 pub body_base64: Option<String>,
62 #[serde(default)]
63 pub timeout_ms: Option<u64>,
64}
65
66#[derive(Debug, Clone, Serialize)]
67#[serde(rename_all = "camelCase")]
68pub struct FetchResponse {
69 pub status: u16,
70 pub status_text: String,
71 pub headers: BTreeMap<String, String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub body: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub body_base64: Option<String>,
76 pub url: String,
77}
78
79#[derive(Debug, Clone, PartialEq)]
81struct Grant {
82 scheme: String,
83 host: String,
85 wildcard: bool,
86 port: u16,
87}
88
89fn parse_grant(permission: &str) -> Option<Grant> {
90 let origin = permission.strip_prefix("net:")?;
91 let (wildcard, parsable) = match origin.split_once("://*.") {
92 Some((scheme, rest)) => (true, format!("{scheme}://{rest}")),
93 None => (false, origin.to_string()),
94 };
95 let u = Url::parse(&parsable).ok()?;
96 if u.scheme() != "http" && u.scheme() != "https" {
97 return None;
98 }
99 Some(Grant {
100 scheme: u.scheme().to_string(),
101 host: u.host_str()?.trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(),
102 wildcard,
103 port: u.port_or_known_default()?,
104 })
105}
106
107fn url_host(url: &Url) -> Option<String> {
108 Some(url.host_str()?.trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase())
109}
110
111impl Grant {
112 fn covers(&self, url: &Url) -> bool {
113 let Some(host) = url_host(url) else { return false };
114 url.scheme() == self.scheme
115 && url.port_or_known_default() == Some(self.port)
116 && if self.wildcard { host.len() > self.host.len() + 1 && host.ends_with(&format!(".{}", self.host)) } else { host == self.host }
117 }
118
119 fn may_reach(&self, ip: IpAddr) -> bool {
123 if ip.is_unspecified() || ip.is_multicast() || matches!(ip, IpAddr::V4(v4) if v4.is_broadcast()) {
124 return false;
125 }
126 if !ip_is_blocked(ip) {
127 return true;
128 }
129 if self.wildcard {
130 return false;
131 }
132 match self.host.parse::<IpAddr>() {
133 Ok(named) => named == ip,
134 Err(_) => self.host == "localhost" && ip.is_loopback(),
135 }
136 }
137}
138
139fn grant_for(granted: &[String], url: &Url) -> Option<Grant> {
141 granted.iter().filter_map(|g| parse_grant(g)).find(|g| g.covers(url))
142}
143
144fn needed(url: &Url) -> String {
147 format!("net:{}", url.origin().ascii_serialization())
148}
149
150async fn resolve(url: &Url, grant: &Grant, pkg_name: &str, deadline: Instant) -> Result<(String, Vec<SocketAddr>), AccessError> {
153 let host = url_host(url).ok_or_else(|| AccessError::invalid("the url has no host"))?;
154 let port = url.port_or_known_default().unwrap_or(443);
155 let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() {
156 Ok(ip) => vec![SocketAddr::new(ip, port)],
157 Err(_) => tokio::time::timeout(
158 DNS_TIMEOUT.min(deadline.saturating_duration_since(Instant::now())),
159 tokio::net::lookup_host((host.as_str(), port)),
160 )
161 .await
162 .map_err(|_| AccessError::network(format!("looking up {host} timed out")))?
163 .map_err(|e| AccessError::network(format!("can't look up {host}: {e}")))?
164 .collect(),
165 };
166 if addrs.is_empty() {
167 return Err(AccessError::network(format!("{host} has no address")));
168 }
169 if let Some(bad) = addrs.iter().find(|a| !grant.may_reach(a.ip())) {
170 return Err(AccessError::denied(
171 pkg_name,
172 &format!("net:{}://{}:{}", url.scheme(), bad.ip(), port),
173 )
174 .with_message(format!(
175 "{host} is a private or local address ({}); only a permission naming that address can reach it",
176 bad.ip()
177 )));
178 }
179 Ok((host, addrs))
180}
181
182impl AccessError {
183 fn with_message(mut self, message: String) -> Self {
184 self.message = message;
185 self
186 }
187}
188
189fn is_text(content_type: &str) -> bool {
190 let ct = content_type.to_ascii_lowercase();
191 ct.starts_with("text/")
192 || ct.contains("json")
193 || ct.contains("xml")
194 || ct.contains("javascript")
195 || ct.contains("x-www-form-urlencoded")
196}
197
198pub async fn fetch(pkg_name: &str, granted: &[String], req: FetchRequest) -> Result<FetchResponse, AccessError> {
200 let mut url = Url::parse(&req.url).map_err(|_| AccessError::invalid("net.fetch needs an absolute http(s) url"))?;
201 if url.scheme() != "http" && url.scheme() != "https" {
202 return Err(AccessError::invalid("net.fetch takes http and https urls only"));
203 }
204 let mut method = reqwest::Method::from_bytes(req.method.as_deref().unwrap_or("GET").to_ascii_uppercase().as_bytes())
205 .map_err(|_| AccessError::invalid("not an HTTP method"))?;
206 let mut headers = reqwest::header::HeaderMap::new();
207 for (name, value) in &req.headers {
208 let lower = name.to_ascii_lowercase();
209 if REFUSED_HEADERS.contains(&lower.as_str()) || lower.starts_with("proxy-") {
210 return Err(AccessError::invalid(format!("a widget can't set the {name} header")));
211 }
212 let n = reqwest::header::HeaderName::from_bytes(lower.as_bytes()).map_err(|_| AccessError::invalid(format!("bad header name {name}")))?;
213 let v = reqwest::header::HeaderValue::from_str(value).map_err(|_| AccessError::invalid(format!("bad value for header {name}")))?;
214 headers.append(n, v);
215 }
216 let mut body: Option<Vec<u8>> = match (&req.body, &req.body_base64) {
217 (Some(_), Some(_)) => return Err(AccessError::invalid("give body or bodyBase64, not both")),
218 (Some(text), None) => Some(text.clone().into_bytes()),
219 (None, Some(b64)) => Some(
220 base64::engine::general_purpose::STANDARD.decode(b64).map_err(|_| AccessError::invalid("bodyBase64 isn't base64"))?,
221 ),
222 (None, None) => None,
223 };
224 if body.as_ref().is_some_and(|b| b.len() > MAX_REQUEST_BYTES) {
225 return Err(AccessError::limit("requestBody", "a request body is limited to 10 MB"));
226 }
227 let timeout = req.timeout_ms.map(Duration::from_millis).unwrap_or(DEFAULT_TIMEOUT).min(MAX_TIMEOUT);
228 let deadline = Instant::now() + timeout;
229
230 for _hop in 0..=MAX_REDIRECTS {
231 let grant = grant_for(granted, &url).ok_or_else(|| AccessError::denied(pkg_name, &needed(&url)))?;
232 let (host, addrs) = resolve(&url, &grant, pkg_name, deadline).await?;
233 let left = deadline.saturating_duration_since(Instant::now());
234 if left.is_zero() {
235 return Err(AccessError::network("the request timed out"));
236 }
237 let client = reqwest::Client::builder()
238 .redirect(reqwest::redirect::Policy::none())
239 .no_proxy()
240 .resolve_to_addrs(&host, &addrs)
241 .timeout(left)
242 .user_agent(concat!("AgentMux-Widget/", env!("CARGO_PKG_VERSION")))
243 .build()
244 .map_err(|e| AccessError::internal(e.to_string()))?;
245 let mut builder = client.request(method.clone(), url.clone()).headers(headers.clone());
246 if let Some(b) = &body {
247 builder = builder.body(b.clone());
248 }
249 let resp = builder.send().await.map_err(|e| {
250 AccessError::network(if e.is_timeout() { "the request timed out".to_string() } else { format!("the request failed: {e}") })
251 })?;
252 let status = resp.status();
253 if status.is_redirection() {
254 if let Some(loc) = resp.headers().get(reqwest::header::LOCATION).and_then(|v| v.to_str().ok()) {
255 let next = url.join(loc).map_err(|_| AccessError::network("a redirect to a bad url"))?;
256 if grant_for(granted, &next).is_none() {
257 return Err(AccessError::network(format!("a redirect to {} isn't allowed", next.origin().ascii_serialization())));
258 }
259 if next.origin() != url.origin() {
262 for name in CREDENTIAL_HEADERS {
263 headers.remove(*name);
264 }
265 }
266 if status == reqwest::StatusCode::SEE_OTHER
268 || ((status == reqwest::StatusCode::MOVED_PERMANENTLY || status == reqwest::StatusCode::FOUND) && method == reqwest::Method::POST)
269 {
270 method = reqwest::Method::GET;
271 body = None;
272 }
273 url = next;
274 continue;
275 }
276 }
277 return read(resp, &url).await;
278 }
279 Err(AccessError::network(format!("more than {MAX_REDIRECTS} redirects")))
280}
281
282async fn read(mut resp: reqwest::Response, url: &Url) -> Result<FetchResponse, AccessError> {
283 let status = resp.status();
284 let mut headers = BTreeMap::new();
285 for (name, value) in resp.headers() {
286 let v = String::from_utf8_lossy(value.as_bytes()).into_owned();
287 headers.entry(name.as_str().to_string()).and_modify(|e: &mut String| {
288 e.push_str(", ");
289 e.push_str(&v);
290 }).or_insert(v);
291 }
292 let mut bytes = Vec::new();
293 while let Some(chunk) = resp.chunk().await.map_err(|e| AccessError::network(format!("reading the response failed: {e}")))? {
294 if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
295 return Err(AccessError::limit("responseBody", "a response is limited to 10 MB"));
296 }
297 bytes.extend_from_slice(&chunk);
298 }
299 let text = headers.get("content-type").is_some_and(|ct| is_text(ct));
300 let (body, body_base64) = match (text, String::from_utf8(bytes)) {
301 (true, Ok(s)) => (Some(s), None),
302 (_, Ok(s)) => (None, Some(base64::engine::general_purpose::STANDARD.encode(s.as_bytes()))),
303 (_, Err(e)) => (None, Some(base64::engine::general_purpose::STANDARD.encode(e.as_bytes()))),
304 };
305 Ok(FetchResponse {
306 status: status.as_u16(),
307 status_text: status.canonical_reason().unwrap_or("").to_string(),
308 headers,
309 body,
310 body_base64,
311 url: url.to_string(),
312 })
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn u(s: &str) -> Url {
320 Url::parse(s).unwrap()
321 }
322
323 #[test]
324 fn grants_cover_exact_origins_and_subdomains_only() {
325 let g = |p: &str| parse_grant(p).unwrap();
326 assert!(g("net:https://api.github.com").covers(&u("https://api.github.com/user")));
327 assert!(!g("net:https://api.github.com").covers(&u("http://api.github.com/user")));
328 assert!(!g("net:https://api.github.com").covers(&u("https://api.github.com:8443/user")));
329 assert!(!g("net:https://api.github.com").covers(&u("https://evil.api.github.com/")));
330 assert!(g("net:https://*.example.com").covers(&u("https://a.example.com/x")));
331 assert!(!g("net:https://*.example.com").covers(&u("https://example.com/x")));
332 assert!(!g("net:https://*.example.com").covers(&u("https://example.com.evil.net/x")));
333 assert!(!g("net:https://*.example.com").covers(&u("https://badexample.com/x")));
334 assert!(g("net:http://127.0.0.1:8188").covers(&u("http://127.0.0.1:8188/prompt")));
335 assert!(!g("net:http://127.0.0.1:8188").covers(&u("http://127.0.0.1:9999/")));
336 assert!(parse_grant("storage").is_none());
337 assert!(parse_grant("net:ftp://x.com").is_none());
338 }
339
340 #[test]
341 fn private_addresses_only_through_a_grant_that_names_them() {
342 let ip = |s: &str| s.parse::<IpAddr>().unwrap();
343 let private = |a: u8, b: u8, c: u8, d: u8| IpAddr::from([a, b, c, d]);
345 let named = parse_grant("net:http://127.0.0.1:8188").unwrap();
346 assert!(named.may_reach(ip("127.0.0.1")));
347 assert!(!named.may_reach(private(10, 0, 0, 5)));
348 let local = parse_grant("net:http://localhost:3000").unwrap();
349 assert!(local.may_reach(ip("127.0.0.1")) && local.may_reach(ip("::1")));
350 assert!(!local.may_reach(private(192, 168, 1, 2)));
351 let lan_ip = private(192, 168, 1, 10);
352 let lan = parse_grant(&format!("net:http://{lan_ip}:8080")).unwrap();
353 assert!(lan.may_reach(lan_ip));
354 let public = parse_grant("net:https://api.example.com").unwrap();
356 assert!(public.may_reach(ip("93.184.216.34")));
357 assert!(!public.may_reach(ip("169.254.169.254")));
358 assert!(!public.may_reach(ip("127.0.0.1")));
359 assert!(!parse_grant("net:https://*.example.com").unwrap().may_reach(private(10, 1, 2, 3)));
360 assert!(!parse_grant("net:http://0.0.0.0:80").unwrap().may_reach(ip("0.0.0.0")));
361 }
362
363 async fn serve(reply: &'static str) -> u16 {
365 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
366 let port = listener.local_addr().unwrap().port();
367 tokio::spawn(async move {
368 while let Ok((mut sock, _)) = listener.accept().await {
369 use tokio::io::{AsyncReadExt, AsyncWriteExt};
370 let mut buf = [0u8; 4096];
371 let _ = sock.read(&mut buf).await;
372 let _ = sock.write_all(reply.as_bytes()).await;
373 let _ = sock.shutdown().await;
374 }
375 });
376 port
377 }
378
379 #[tokio::test]
380 async fn fetches_through_a_grant_and_refuses_without_one() {
381 let port = serve("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}").await;
382 let url = format!("http://127.0.0.1:{port}/x");
383 let granted = vec![format!("net:http://127.0.0.1:{port}")];
384 let resp = fetch("Test", &granted, FetchRequest { url: url.clone(), ..Default::default() }).await.unwrap();
385 assert_eq!(resp.status, 200);
386 assert_eq!(resp.body.as_deref(), Some("{\"ok\":true}"));
387 assert_eq!(resp.headers.get("content-type").map(String::as_str), Some("application/json"));
388
389 let err = fetch("Test", &[], FetchRequest { url: url.clone(), ..Default::default() }).await.unwrap_err();
390 assert_eq!(err.code, 1001);
391 assert_eq!(err.data, Some(serde_json::json!({ "permission": format!("net:http://127.0.0.1:{port}") })));
392 let err = fetch("Test", &["net:https://*.example.com".to_string()], FetchRequest { url, ..Default::default() }).await.unwrap_err();
394 assert_eq!(err.code, 1001);
395 }
396
397 #[tokio::test]
398 async fn a_redirect_must_stay_within_the_grants() {
399 let target = serve("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi").await;
400 let reply: &'static str = Box::leak(
401 format!("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{target}/there\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").into_boxed_str(),
402 );
403 let first = serve(reply).await;
404 let url = format!("http://127.0.0.1:{first}/");
405 let only_first = vec![format!("net:http://127.0.0.1:{first}")];
406 let err = fetch("Test", &only_first, FetchRequest { url: url.clone(), ..Default::default() }).await.unwrap_err();
407 assert_eq!(err.code, 1004);
408 let both = vec![format!("net:http://127.0.0.1:{first}"), format!("net:http://127.0.0.1:{target}")];
409 let resp = fetch("Test", &both, FetchRequest { url, ..Default::default() }).await.unwrap();
410 assert_eq!(resp.url, format!("http://127.0.0.1:{target}/there"));
411 assert_eq!(resp.body_base64.as_deref(), Some("aGk="));
413 }
414
415 async fn serve_auth_echo() -> u16 {
417 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
418 let port = listener.local_addr().unwrap().port();
419 tokio::spawn(async move {
420 while let Ok((mut sock, _)) = listener.accept().await {
421 use tokio::io::{AsyncReadExt, AsyncWriteExt};
422 let mut buf = [0u8; 4096];
423 let n = sock.read(&mut buf).await.unwrap_or(0);
424 let req = String::from_utf8_lossy(&buf[..n]).to_ascii_lowercase();
425 let body = if req.contains("\r\nauthorization:") { "with-auth" } else { "no-auth" };
426 let reply = format!("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
427 let _ = sock.write_all(reply.as_bytes()).await;
428 let _ = sock.shutdown().await;
429 }
430 });
431 port
432 }
433
434 #[tokio::test]
435 async fn credentials_dont_follow_a_redirect_to_another_origin() {
436 let target = serve_auth_echo().await;
437 let reply: &'static str = Box::leak(
438 format!("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{target}/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").into_boxed_str(),
439 );
440 let first = serve(reply).await;
441 let both = vec![format!("net:http://127.0.0.1:{first}"), format!("net:http://127.0.0.1:{target}")];
442 let headers = BTreeMap::from([("Authorization".to_string(), "Bearer secret".to_string())]);
443 let resp = fetch("Test", &both, FetchRequest { url: format!("http://127.0.0.1:{first}/"), headers: headers.clone(), ..Default::default() })
444 .await
445 .unwrap();
446 assert_eq!(resp.body.as_deref(), Some("no-auth"), "dropped on the way to another origin");
447 let resp = fetch("Test", &both, FetchRequest { url: format!("http://127.0.0.1:{target}/"), headers, ..Default::default() }).await.unwrap();
449 assert_eq!(resp.body.as_deref(), Some("with-auth"));
450 }
451
452 #[test]
453 fn refuses_headers_that_belong_to_the_connection() {
454 let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
455 let err = rt
456 .block_on(fetch(
457 "Test",
458 &["net:https://api.example.com".to_string()],
459 FetchRequest { url: "https://api.example.com/".into(), headers: BTreeMap::from([("Host".into(), "evil".into())]), ..Default::default() },
460 ))
461 .unwrap_err();
462 assert_eq!(err.code, -32602);
463 }
464}