agentmux_srv\drone\executor\blocks/
api.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! API block — HTTP request via reqwest. Honors `{{...}}` interpolation
5//! in url, headers, and body.
6//!
7//! Config (`node.data`):
8//!   * `method` — "GET" / "POST" / "PUT" / "DELETE" / "PATCH" (default GET)
9//!   * `url` — required; supports `{{...}}`
10//!   * `headers` — optional `{ name: value }` map; values support `{{...}}`
11//!   * `body` — optional string body (POST/PUT/PATCH); supports `{{...}}`
12//!
13//! Output:
14//!   ```json
15//!   { "status": 200, "body": <parsed-json-or-text>, "headers": { ... } }
16//!   ```
17
18use std::collections::HashMap;
19use std::net::{Ipv4Addr, Ipv6Addr};
20use std::sync::OnceLock;
21use std::time::Duration;
22
23use serde_json::{json, Value};
24
25use crate::drone::data_flow::ExecutionScope;
26use crate::drone::types::FlowNode;
27
28const DEFAULT_TIMEOUT_MS: u64 = 30_000;
29
30/// Shared `reqwest::Client` so drones with multiple API blocks
31/// reuse one connection pool instead of building a new pool per
32/// request. Per-request timeouts move to the RequestBuilder.
33/// (reagent P2 on PR #755.)
34static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
35
36fn http_client() -> &'static reqwest::Client {
37    HTTP_CLIENT.get_or_init(|| {
38        reqwest::Client::builder()
39            // Re-run SSRF validation on every redirect target —
40            // otherwise a public attacker-controlled URL can return
41            // `302 Location: http://169.254.169.254/...` and bypass
42            // the initial check. (codex P1 on PR #755.)
43            .redirect(reqwest::redirect::Policy::custom(|attempt| {
44                if let Err(e) = validate_url_for_safety(attempt.url()) {
45                    return attempt.error(format!("redirect blocked: {e}"));
46                }
47                if attempt.previous().len() >= 10 {
48                    return attempt.error("too many redirects");
49                }
50                attempt.follow()
51            }))
52            .build()
53            .expect("reqwest client build failed")
54    })
55}
56
57/// Validate a resolved URL before dispatching it. Phase 1 SSRF
58/// protection: rejects non-http(s) schemes and literal-IP hosts that
59/// fall into reserved / link-local / private / loopback ranges (the
60/// AWS metadata endpoint 169.254.169.254, RFC1918 space, 127.0.0.1,
61/// fc00::/7, ::1, etc.). DNS-resolved hostnames are not re-checked
62/// post-resolution — that requires a custom `reqwest` resolver and
63/// lands as a follow-up issue. See kimi P1 on PR #755.
64fn validate_url_safety(url_str: &str) -> Result<(), String> {
65    let url = reqwest::Url::parse(url_str)
66        .map_err(|e| format!("invalid URL: {e}"))?;
67    validate_url_for_safety(&url)
68}
69
70/// Same checks as `validate_url_safety` but takes a pre-parsed
71/// `reqwest::Url`. The redirect-policy closure receives Url values
72/// directly, so this avoids a parse round-trip on every hop.
73fn validate_url_for_safety(url: &reqwest::Url) -> Result<(), String> {
74    match url.scheme() {
75        "http" | "https" => {}
76        other => return Err(format!("URL scheme `{other}` is not allowed (http/https only)")),
77    }
78    let host = url
79        .host_str()
80        .ok_or_else(|| "URL missing host".to_string())?;
81    // IPv6 literals in URLs are bracketed: `https://[::1]/`. Strip the
82    // brackets before attempting an IP parse; that path also lets us
83    // distinguish a true IPv6 literal from an ambiguous string.
84    if let Some(inner) = host.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
85        let v6: Ipv6Addr = inner
86            .parse()
87            .map_err(|e| format!("invalid IPv6 host `{inner}`: {e}"))?;
88        if is_reserved_v6(&v6) {
89            return Err(format!("host `{v6}` is a reserved IPv6 address"));
90        }
91        return Ok(());
92    }
93    if host.eq_ignore_ascii_case("localhost") {
94        return Err("host `localhost` is not allowed".to_string());
95    }
96    if let Ok(v4) = host.parse::<Ipv4Addr>() {
97        if is_reserved_v4(&v4) {
98            return Err(format!("host `{v4}` is a reserved/private IP"));
99        }
100    }
101    // Otherwise it's a domain name — DNS-resolution-time SSRF
102    // validation is out of scope for Phase 1 (see fn doc-comment).
103    Ok(())
104}
105
106fn is_reserved_v4(v4: &Ipv4Addr) -> bool {
107    v4.is_loopback()
108        || v4.is_private()
109        || v4.is_link_local()
110        || v4.is_broadcast()
111        || v4.is_unspecified()
112        || v4.is_multicast()
113}
114
115fn is_reserved_v6(v6: &Ipv6Addr) -> bool {
116    // Check pure-v6 reserved ranges first. Order matters: `::1` is
117    // v6 loopback AND happens to satisfy `to_ipv4()` (lower 32 bits
118    // map to 0.0.0.1, a public v4). Catching loopback up here avoids
119    // misclassifying it as "public v4 embedded in v6".
120    if v6.is_loopback()
121        || v6.is_unspecified()
122        || v6.is_multicast()
123        // unique-local fc00::/7 — stable API for this is gated, so
124        // check the segment manually.
125        || (v6.segments()[0] & 0xfe00) == 0xfc00
126        // link-local fe80::/10
127        || (v6.segments()[0] & 0xffc0) == 0xfe80
128    {
129        return true;
130    }
131    // IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-compatible (`::a.b.c.d`,
132    // deprecated) literals route to the embedded IPv4 address on
133    // most kernels, bypassing v6-only checks. Delegate to the v4
134    // predicate so private/loopback IPv4 in v6 form is caught.
135    // (codex P1 on PR #755.)
136    if let Some(v4) = v6.to_ipv4_mapped() {
137        return is_reserved_v4(&v4);
138    }
139    #[allow(deprecated)]
140    if let Some(v4) = v6.to_ipv4() {
141        // `to_ipv4` returns Some for mapped (handled above) AND
142        // IPv4-compatible (`::a.b.c.d`). This branch catches the
143        // compatible form.
144        return is_reserved_v4(&v4);
145    }
146    false
147}
148
149pub async fn run(node: &FlowNode, scope: &ExecutionScope) -> Result<Value, String> {
150    let method_raw = node
151        .data
152        .get("method")
153        .and_then(|v| v.as_str())
154        .unwrap_or("GET")
155        .to_uppercase();
156    let url_raw = node
157        .data
158        .get("url")
159        .and_then(|v| v.as_str())
160        .ok_or_else(|| "API block missing `url`".to_string())?;
161    let url = scope.resolve(url_raw);
162    if url.trim().is_empty() {
163        return Err("API block resolved URL is empty".to_string());
164    }
165    validate_url_safety(&url)?;
166
167    let headers_map: HashMap<String, String> = match node.data.get("headers") {
168        Some(Value::Object(obj)) => obj
169            .iter()
170            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), scope.resolve(s))))
171            .collect(),
172        _ => HashMap::new(),
173    };
174
175    let body_resolved: Option<String> = node
176        .data
177        .get("body")
178        .and_then(|v| v.as_str())
179        .map(|s| scope.resolve(s));
180
181    let timeout_ms = node
182        .data
183        .get("timeout_ms")
184        .and_then(|v| v.as_u64())
185        .unwrap_or(DEFAULT_TIMEOUT_MS);
186
187    let client = http_client();
188    let method = reqwest::Method::from_bytes(method_raw.as_bytes())
189        .map_err(|e| format!("invalid method `{method_raw}`: {e}"))?;
190    let mut req = client
191        .request(method, &url)
192        .timeout(Duration::from_millis(timeout_ms));
193    for (k, v) in &headers_map {
194        req = req.header(k, v);
195    }
196    if let Some(b) = body_resolved {
197        if !b.is_empty() {
198            req = req.body(b);
199        }
200    }
201    let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
202    let status = resp.status().as_u16();
203    let resp_headers: HashMap<String, String> = resp
204        .headers()
205        .iter()
206        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
207        .collect();
208    let bytes = resp.bytes().await.map_err(|e| format!("read body: {e}"))?;
209    let text = String::from_utf8_lossy(&bytes).to_string();
210    // Try to parse as JSON for downstream `{{api.body.field}}` access.
211    let body_val: Value = serde_json::from_str(&text).unwrap_or(Value::String(text));
212
213    Ok(json!({
214        "status": status,
215        "body": body_val,
216        "headers": resp_headers,
217    }))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn ssrf_rejects_loopback() {
226        assert!(validate_url_safety("http://127.0.0.1/").is_err());
227        assert!(validate_url_safety("http://127.0.0.1:8080/x").is_err());
228        assert!(validate_url_safety("https://[::1]/").is_err());
229    }
230
231    #[test]
232    fn ssrf_rejects_localhost_hostname() {
233        assert!(validate_url_safety("http://localhost/").is_err());
234        assert!(validate_url_safety("http://LocalHost:80/").is_err());
235    }
236
237    #[test]
238    fn ssrf_rejects_aws_metadata_endpoint() {
239        // 169.254.169.254 is link-local (RFC 3927) — covers the cloud
240        // metadata endpoints for AWS, GCP, Azure (all use this address
241        // or other link-local IPs).
242        assert!(validate_url_safety("http://169.254.169.254/latest/meta-data/").is_err());
243    }
244
245    #[test]
246    fn ssrf_rejects_rfc1918_private() {
247        assert!(validate_url_safety("http://10.0.0.1/").is_err());
248        assert!(validate_url_safety("http://172.16.0.1/").is_err());
249        assert!(validate_url_safety("http://192.168.1.1/").is_err());
250    }
251
252    #[test]
253    fn ssrf_rejects_non_http_schemes() {
254        assert!(validate_url_safety("file:///etc/passwd").is_err());
255        assert!(validate_url_safety("ftp://example.com/").is_err());
256        assert!(validate_url_safety("gopher://example.com/").is_err());
257    }
258
259    #[test]
260    fn ssrf_rejects_ipv6_unique_local() {
261        // fc00::/7 — RFC 4193 unique local addresses.
262        assert!(validate_url_safety("http://[fc00::1]/").is_err());
263        assert!(validate_url_safety("http://[fd00::1]/").is_err());
264    }
265
266    #[test]
267    fn ssrf_rejects_ipv4_mapped_ipv6_to_reserved() {
268        // ::ffff:a.b.c.d routes to a.b.c.d on most kernels. The v6
269        // path must delegate to the v4 reserved check or these slip
270        // past the SSRF guard. (codex P1.)
271        assert!(validate_url_safety("http://[::ffff:127.0.0.1]/").is_err());
272        assert!(validate_url_safety("http://[::ffff:169.254.169.254]/").is_err());
273        assert!(validate_url_safety("http://[::ffff:10.0.0.1]/").is_err());
274        assert!(validate_url_safety("http://[::ffff:192.168.1.1]/").is_err());
275    }
276
277    #[test]
278    fn ssrf_rejects_ipv4_compatible_ipv6_to_reserved() {
279        // ::a.b.c.d (IPv4-compatible, deprecated form). Defense in
280        // depth — some kernels still honor it.
281        assert!(validate_url_safety("http://[::127.0.0.1]/").is_err());
282    }
283
284    #[test]
285    fn ssrf_allows_public_ipv4_in_mapped_form() {
286        // Mapped form of a public IP is consistent with allowing the
287        // plain form — block only the reserved range, not the wrapper.
288        assert!(validate_url_safety("https://[::ffff:8.8.8.8]/").is_ok());
289    }
290
291    #[test]
292    fn ssrf_allows_public_hostnames() {
293        assert!(validate_url_safety("https://api.example.com/v1/users").is_ok());
294        assert!(validate_url_safety("http://example.com:8080/").is_ok());
295    }
296
297    #[test]
298    fn ssrf_allows_public_ip_literal() {
299        // 8.8.8.8 is a public DNS address — not reserved.
300        assert!(validate_url_safety("https://8.8.8.8/").is_ok());
301    }
302}