Skip to main content

agentmux_srv/backend/
widget_net.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `net.fetch` for sandboxed widgets
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md ยง6.3).
6//!
7//! srv makes the request, not the widget: no cookies, no proxy, no browser
8//! profile, no AgentMux origin. The URL's origin must match one of the
9//! package's `net:` grants, and so must every redirect. The host's addresses
10//! are resolved once and the connection is pinned to them, so a name can't
11//! resolve to one address for the check and another for the request. A
12//! private or local address is reachable only through a grant that names it
13//! as an IP address, or `localhost` for loopback, port included.
14
15use 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
33/// Request headers a widget can't set: srv sets them, or they'd change how
34/// the connection itself behaves.
35const 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
47/// Headers dropped when a redirect leaves the origin they were sent to.
48const 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/// One `net:` grant, parsed.
80#[derive(Debug, Clone, PartialEq)]
81struct Grant {
82    scheme: String,
83    /// The host, or the domain under `*.`.
84    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    /// May a request under this grant reach `ip`? A public address, yes. A
120    /// private or local one only when the grant names it: the same IP
121    /// address, or `localhost` for loopback.
122    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
139/// The grant among `granted` that covers `url`, if any.
140fn grant_for(granted: &[String], url: &Url) -> Option<Grant> {
141    granted.iter().filter_map(|g| parse_grant(g)).find(|g| g.covers(url))
142}
143
144/// The permission a request to `url` needs, named the way the manifest
145/// writes it (for the error).
146fn needed(url: &Url) -> String {
147    format!("net:{}", url.origin().ascii_serialization())
148}
149
150/// The host's addresses, looked up within `deadline` (the request's own
151/// timeout covers DNS too), each checked against `grant`.
152async 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
198/// Makes the request for a package granted `granted`.
199pub 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                // As browsers do: credentials meant for one origin don't
260                // follow a redirect to another, even an allowed one.
261                if next.origin() != url.origin() {
262                    for name in CREDENTIAL_HEADERS {
263                        headers.remove(*name);
264                    }
265                }
266                // As browsers do: 303, and 301/302 after a POST, become a GET.
267                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        // Private addresses built from their octets: made-up test values.
344        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        // A public name that resolves somewhere private: refused.
355        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    /// A one-shot HTTP server on 127.0.0.1 answering every request with `reply`.
364    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        // A grant for a public name doesn't reach loopback through it.
393        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        // No content type: the bytes come back as base64.
412        assert_eq!(resp.body_base64.as_deref(), Some("aGk="));
413    }
414
415    /// A one-shot server that answers whether the request carried Authorization.
416    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        // Straight to it, it's sent.
448        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}