seq_runtime/
http_client.rs

1//! HTTP client operations for Seq
2//!
3//! These functions are exported with C ABI for LLVM codegen to call.
4//!
5//! # API
6//!
7//! ```seq
8//! # GET request
9//! "https://api.example.com/users" http.get
10//! # Stack: ( Map ) where Map = { "status": 200, "body": "...", "ok": true }
11//!
12//! # POST request
13//! "https://api.example.com/users" "{\"name\":\"Alice\"}" "application/json" http.post
14//! # Stack: ( Map ) where Map = { "status": 201, "body": "...", "ok": true }
15//!
16//! # Check response
17//! dup "ok" map.get if
18//!   "body" map.get json.decode  # Process JSON body
19//! else
20//!   "error" map.get io.write-line  # Handle error
21//! then
22//! ```
23//!
24//! # Response Map
25//!
26//! All HTTP operations return a Map with:
27//! - `"status"` (Int): HTTP status code (200, 404, 500, etc.) or 0 on connection error
28//! - `"body"` (String): Response body as text
29//! - `"ok"` (Bool): true if status is 2xx, false otherwise
30//! - `"error"` (String): Error message (only present on failure)
31//!
32//! # Security: SSRF Protection
33//!
34//! This HTTP client includes built-in protection against Server-Side Request Forgery
35//! (SSRF) attacks. The following are automatically blocked:
36//!
37//! - **Localhost**: `localhost`, `*.localhost`, `127.x.x.x`
38//! - **Private networks**: `10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`
39//! - **Link-local/Cloud metadata**: `169.254.x.x` (blocks AWS/GCP/Azure metadata endpoints)
40//! - **IPv6 private**: loopback (`::1`), link-local (`fe80::/10`), unique local (`fc00::/7`)
41//! - **Non-HTTP schemes**: `file://`, `ftp://`, `gopher://`, etc.
42//!
43//! Blocked requests return an error response with `ok=false` and an explanatory message.
44//!
45//! **Additional recommendations for defense in depth**:
46//! - Use domain allowlists for sensitive applications
47//! - Apply network-level egress filtering
48//!
49//! # Resource Limits
50//!
51//! - **Timeout**: 30 seconds per request (prevents indefinite hangs)
52//! - **Max body size**: 10 MB (prevents memory exhaustion)
53//! - **TLS**: Enabled by default via rustls (no OpenSSL dependency)
54//! - **Connection pooling**: Enabled via shared agent instance
55
56use crate::seqstring::global_string;
57use crate::stack::{Stack, pop, push};
58use crate::value::{MapKey, Value};
59
60use std::collections::HashMap;
61use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
62use std::sync::LazyLock;
63use std::time::Duration;
64
65/// Default timeout for HTTP requests (30 seconds)
66const DEFAULT_TIMEOUT_SECS: u64 = 30;
67
68/// Maximum response body size (10 MB)
69const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
70
71/// Global HTTP agent for connection pooling
72/// Using LazyLock for thread-safe lazy initialization
73static HTTP_AGENT: LazyLock<ureq::Agent> = LazyLock::new(|| {
74    ureq::AgentBuilder::new()
75        .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
76        .build()
77});
78
79/// Check if an IPv4 address is in a private/dangerous range
80fn is_dangerous_ipv4(ip: Ipv4Addr) -> bool {
81    // Loopback: 127.0.0.0/8
82    if ip.is_loopback() {
83        return true;
84    }
85    // Private: 10.0.0.0/8
86    if ip.octets()[0] == 10 {
87        return true;
88    }
89    // Private: 172.16.0.0/12
90    if ip.octets()[0] == 172 && (ip.octets()[1] >= 16 && ip.octets()[1] <= 31) {
91        return true;
92    }
93    // Private: 192.168.0.0/16
94    if ip.octets()[0] == 192 && ip.octets()[1] == 168 {
95        return true;
96    }
97    // Link-local: 169.254.0.0/16 (includes cloud metadata endpoints)
98    if ip.octets()[0] == 169 && ip.octets()[1] == 254 {
99        return true;
100    }
101    // Broadcast
102    if ip.is_broadcast() {
103        return true;
104    }
105    false
106}
107
108/// Check if an IPv6 address is in a private/dangerous range
109fn is_dangerous_ipv6(ip: Ipv6Addr) -> bool {
110    // Loopback: ::1
111    if ip.is_loopback() {
112        return true;
113    }
114    // Link-local: fe80::/10
115    let segments = ip.segments();
116    if (segments[0] & 0xffc0) == 0xfe80 {
117        return true;
118    }
119    // Unique local: fc00::/7
120    if (segments[0] & 0xfe00) == 0xfc00 {
121        return true;
122    }
123    // IPv4-mapped IPv6 addresses: check the embedded IPv4
124    if let Some(ipv4) = ip.to_ipv4_mapped() {
125        return is_dangerous_ipv4(ipv4);
126    }
127    false
128}
129
130/// Check if an IP address is dangerous (private, loopback, link-local, etc.)
131fn is_dangerous_ip(ip: IpAddr) -> bool {
132    match ip {
133        IpAddr::V4(v4) => is_dangerous_ipv4(v4),
134        IpAddr::V6(v6) => is_dangerous_ipv6(v6),
135    }
136}
137
138/// Validate URL for SSRF protection
139/// Returns Ok(()) if safe, Err(message) if blocked
140fn validate_url_for_ssrf(url: &str) -> Result<(), String> {
141    // Parse the URL
142    let parsed = match url::Url::parse(url) {
143        Ok(u) => u,
144        Err(e) => return Err(format!("Invalid URL: {}", e)),
145    };
146
147    // Only allow http and https schemes
148    match parsed.scheme() {
149        "http" | "https" => {}
150        scheme => {
151            return Err(format!(
152                "Blocked scheme '{}': only http/https allowed",
153                scheme
154            ));
155        }
156    }
157
158    // Get the host
159    let host = match parsed.host_str() {
160        Some(h) => h,
161        None => return Err("URL has no host".to_string()),
162    };
163
164    // Block obvious localhost variants
165    let host_lower = host.to_lowercase();
166    if host_lower == "localhost"
167        || host_lower == "localhost.localdomain"
168        || host_lower.ends_with(".localhost")
169    {
170        return Err("Blocked: localhost access not allowed".to_string());
171    }
172
173    // Get port (default to 80/443)
174    let port = parsed
175        .port()
176        .unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
177
178    // Resolve hostname to IP addresses and check each one
179    let addr_str = format!("{}:{}", host, port);
180    match addr_str.to_socket_addrs() {
181        Ok(addrs) => {
182            for addr in addrs {
183                if is_dangerous_ip(addr.ip()) {
184                    return Err(format!(
185                        "Blocked: {} resolves to private/internal IP {}",
186                        host,
187                        addr.ip()
188                    ));
189                }
190            }
191        }
192        Err(_) => {
193            // DNS resolution failed - allow the request to proceed
194            // (ureq will handle the DNS error appropriately)
195        }
196    }
197
198    Ok(())
199}
200
201/// Build a response map from status, body, ok flag, and optional error
202fn build_response_map(status: i64, body: String, ok: bool, error: Option<String>) -> Value {
203    let mut map: HashMap<MapKey, Value> = HashMap::new();
204
205    map.insert(
206        MapKey::String(global_string("status".to_string())),
207        Value::Int(status),
208    );
209    map.insert(
210        MapKey::String(global_string("body".to_string())),
211        Value::String(global_string(body)),
212    );
213    map.insert(
214        MapKey::String(global_string("ok".to_string())),
215        Value::Bool(ok),
216    );
217
218    if let Some(err) = error {
219        map.insert(
220            MapKey::String(global_string("error".to_string())),
221            Value::String(global_string(err)),
222        );
223    }
224
225    Value::Map(Box::new(map))
226}
227
228/// Build an error response map
229fn error_response(error: String) -> Value {
230    build_response_map(0, String::new(), false, Some(error))
231}
232
233/// Perform HTTP GET request
234///
235/// Stack effect: ( url -- response )
236///
237/// Returns a Map with status, body, ok, and optionally error.
238///
239/// # Safety
240/// Stack must have a String (URL) on top
241#[unsafe(no_mangle)]
242pub unsafe extern "C" fn patch_seq_http_get(stack: Stack) -> Stack {
243    assert!(!stack.is_null(), "http.get: stack is empty");
244
245    let (stack, url_value) = unsafe { pop(stack) };
246
247    match url_value {
248        Value::String(url) => {
249            let response = perform_get(url.as_str());
250            unsafe { push(stack, response) }
251        }
252        _ => panic!(
253            "http.get: expected String (URL) on stack, got {:?}",
254            url_value
255        ),
256    }
257}
258
259/// Perform HTTP POST request
260///
261/// Stack effect: ( url body content-type -- response )
262///
263/// Returns a Map with status, body, ok, and optionally error.
264///
265/// # Safety
266/// Stack must have three String values on top (url, body, content-type)
267#[unsafe(no_mangle)]
268pub unsafe extern "C" fn patch_seq_http_post(stack: Stack) -> Stack {
269    assert!(!stack.is_null(), "http.post: stack is empty");
270
271    let (stack, content_type_value) = unsafe { pop(stack) };
272    let (stack, body_value) = unsafe { pop(stack) };
273    let (stack, url_value) = unsafe { pop(stack) };
274
275    match (url_value, body_value, content_type_value) {
276        (Value::String(url), Value::String(body), Value::String(content_type)) => {
277            let response = perform_post(url.as_str(), body.as_str(), content_type.as_str());
278            unsafe { push(stack, response) }
279        }
280        (url, body, ct) => panic!(
281            "http.post: expected (String, String, String) on stack, got ({:?}, {:?}, {:?})",
282            url, body, ct
283        ),
284    }
285}
286
287/// Perform HTTP PUT request
288///
289/// Stack effect: ( url body content-type -- response )
290///
291/// Returns a Map with status, body, ok, and optionally error.
292///
293/// # Safety
294/// Stack must have three String values on top (url, body, content-type)
295#[unsafe(no_mangle)]
296pub unsafe extern "C" fn patch_seq_http_put(stack: Stack) -> Stack {
297    assert!(!stack.is_null(), "http.put: stack is empty");
298
299    let (stack, content_type_value) = unsafe { pop(stack) };
300    let (stack, body_value) = unsafe { pop(stack) };
301    let (stack, url_value) = unsafe { pop(stack) };
302
303    match (url_value, body_value, content_type_value) {
304        (Value::String(url), Value::String(body), Value::String(content_type)) => {
305            let response = perform_put(url.as_str(), body.as_str(), content_type.as_str());
306            unsafe { push(stack, response) }
307        }
308        (url, body, ct) => panic!(
309            "http.put: expected (String, String, String) on stack, got ({:?}, {:?}, {:?})",
310            url, body, ct
311        ),
312    }
313}
314
315/// Perform HTTP DELETE request
316///
317/// Stack effect: ( url -- response )
318///
319/// Returns a Map with status, body, ok, and optionally error.
320///
321/// # Safety
322/// Stack must have a String (URL) on top
323#[unsafe(no_mangle)]
324pub unsafe extern "C" fn patch_seq_http_delete(stack: Stack) -> Stack {
325    assert!(!stack.is_null(), "http.delete: stack is empty");
326
327    let (stack, url_value) = unsafe { pop(stack) };
328
329    match url_value {
330        Value::String(url) => {
331            let response = perform_delete(url.as_str());
332            unsafe { push(stack, response) }
333        }
334        _ => panic!(
335            "http.delete: expected String (URL) on stack, got {:?}",
336            url_value
337        ),
338    }
339}
340
341/// Handle HTTP response result and convert to Value
342fn handle_response(result: Result<ureq::Response, ureq::Error>) -> Value {
343    match result {
344        Ok(response) => {
345            let status = response.status() as i64;
346            let ok = (200..300).contains(&response.status());
347
348            match response.into_string() {
349                Ok(body) => {
350                    if body.len() > MAX_BODY_SIZE {
351                        error_response(format!(
352                            "Response body too large ({} bytes, max {})",
353                            body.len(),
354                            MAX_BODY_SIZE
355                        ))
356                    } else {
357                        build_response_map(status, body, ok, None)
358                    }
359                }
360                Err(e) => error_response(format!("Failed to read response body: {}", e)),
361            }
362        }
363        Err(ureq::Error::Status(code, response)) => {
364            // HTTP error status (4xx, 5xx)
365            let body = response.into_string().unwrap_or_default();
366            build_response_map(
367                code as i64,
368                body,
369                false,
370                Some(format!("HTTP error: {}", code)),
371            )
372        }
373        Err(ureq::Error::Transport(e)) => {
374            // Connection/transport error
375            error_response(format!("Connection error: {}", e))
376        }
377    }
378}
379
380/// Internal: Perform GET request
381fn perform_get(url: &str) -> Value {
382    // SSRF protection: validate URL before making request
383    if let Err(msg) = validate_url_for_ssrf(url) {
384        return error_response(msg);
385    }
386    handle_response(HTTP_AGENT.get(url).call())
387}
388
389/// Internal: Perform POST request
390fn perform_post(url: &str, body: &str, content_type: &str) -> Value {
391    // SSRF protection: validate URL before making request
392    if let Err(msg) = validate_url_for_ssrf(url) {
393        return error_response(msg);
394    }
395    handle_response(
396        HTTP_AGENT
397            .post(url)
398            .set("Content-Type", content_type)
399            .send_string(body),
400    )
401}
402
403/// Internal: Perform PUT request
404fn perform_put(url: &str, body: &str, content_type: &str) -> Value {
405    // SSRF protection: validate URL before making request
406    if let Err(msg) = validate_url_for_ssrf(url) {
407        return error_response(msg);
408    }
409    handle_response(
410        HTTP_AGENT
411            .put(url)
412            .set("Content-Type", content_type)
413            .send_string(body),
414    )
415}
416
417/// Internal: Perform DELETE request
418fn perform_delete(url: &str) -> Value {
419    // SSRF protection: validate URL before making request
420    if let Err(msg) = validate_url_for_ssrf(url) {
421        return error_response(msg);
422    }
423    handle_response(HTTP_AGENT.delete(url).call())
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    // Note: HTTP tests require network access and a running server
431    // Unit tests here focus on the response building logic
432
433    #[test]
434    fn test_build_response_map_success() {
435        let response = build_response_map(200, "Hello".to_string(), true, None);
436
437        match response {
438            Value::Map(map_data) => {
439                let map = map_data.as_ref();
440
441                // Check status
442                let status_key = MapKey::String(global_string("status".to_string()));
443                assert!(matches!(map.get(&status_key), Some(Value::Int(200))));
444
445                // Check body
446                let body_key = MapKey::String(global_string("body".to_string()));
447                if let Some(Value::String(s)) = map.get(&body_key) {
448                    assert_eq!(s.as_str(), "Hello");
449                } else {
450                    panic!("Expected body to be String");
451                }
452
453                // Check ok
454                let ok_key = MapKey::String(global_string("ok".to_string()));
455                assert!(matches!(map.get(&ok_key), Some(Value::Bool(true))));
456
457                // Check no error key
458                let error_key = MapKey::String(global_string("error".to_string()));
459                assert!(map.get(&error_key).is_none());
460            }
461            _ => panic!("Expected Map"),
462        }
463    }
464
465    #[test]
466    fn test_build_response_map_error() {
467        let response = build_response_map(404, String::new(), false, Some("Not Found".to_string()));
468
469        match response {
470            Value::Map(map_data) => {
471                let map = map_data.as_ref();
472
473                // Check status
474                let status_key = MapKey::String(global_string("status".to_string()));
475                assert!(matches!(map.get(&status_key), Some(Value::Int(404))));
476
477                // Check ok is false
478                let ok_key = MapKey::String(global_string("ok".to_string()));
479                assert!(matches!(map.get(&ok_key), Some(Value::Bool(false))));
480
481                // Check error message
482                let error_key = MapKey::String(global_string("error".to_string()));
483                if let Some(Value::String(s)) = map.get(&error_key) {
484                    assert_eq!(s.as_str(), "Not Found");
485                } else {
486                    panic!("Expected error to be String");
487                }
488            }
489            _ => panic!("Expected Map"),
490        }
491    }
492
493    #[test]
494    fn test_error_response() {
495        let response = error_response("Connection refused".to_string());
496
497        match response {
498            Value::Map(map_data) => {
499                let map = map_data.as_ref();
500
501                // Check status is 0
502                let status_key = MapKey::String(global_string("status".to_string()));
503                assert!(matches!(map.get(&status_key), Some(Value::Int(0))));
504
505                // Check ok is false
506                let ok_key = MapKey::String(global_string("ok".to_string()));
507                assert!(matches!(map.get(&ok_key), Some(Value::Bool(false))));
508
509                // Check error message
510                let error_key = MapKey::String(global_string("error".to_string()));
511                if let Some(Value::String(s)) = map.get(&error_key) {
512                    assert_eq!(s.as_str(), "Connection refused");
513                } else {
514                    panic!("Expected error to be String");
515                }
516            }
517            _ => panic!("Expected Map"),
518        }
519    }
520
521    // SSRF protection tests
522
523    #[test]
524    fn test_ssrf_blocks_localhost() {
525        assert!(validate_url_for_ssrf("http://localhost/").is_err());
526        assert!(validate_url_for_ssrf("http://localhost:8080/").is_err());
527        assert!(validate_url_for_ssrf("http://LOCALHOST/").is_err());
528        assert!(validate_url_for_ssrf("http://test.localhost/").is_err());
529    }
530
531    #[test]
532    fn test_ssrf_blocks_loopback_ip() {
533        assert!(validate_url_for_ssrf("http://127.0.0.1/").is_err());
534        assert!(validate_url_for_ssrf("http://127.0.0.1:8080/").is_err());
535        assert!(validate_url_for_ssrf("http://127.1.2.3/").is_err());
536    }
537
538    #[test]
539    fn test_ssrf_blocks_private_ranges() {
540        // 10.0.0.0/8
541        assert!(validate_url_for_ssrf("http://10.0.0.1/").is_err());
542        assert!(validate_url_for_ssrf("http://10.255.255.255/").is_err());
543
544        // 172.16.0.0/12
545        assert!(validate_url_for_ssrf("http://172.16.0.1/").is_err());
546        assert!(validate_url_for_ssrf("http://172.31.255.255/").is_err());
547
548        // 192.168.0.0/16
549        assert!(validate_url_for_ssrf("http://192.168.0.1/").is_err());
550        assert!(validate_url_for_ssrf("http://192.168.255.255/").is_err());
551    }
552
553    #[test]
554    fn test_ssrf_blocks_link_local() {
555        // Cloud metadata endpoint
556        assert!(validate_url_for_ssrf("http://169.254.169.254/").is_err());
557        assert!(validate_url_for_ssrf("http://169.254.0.1/").is_err());
558    }
559
560    #[test]
561    fn test_ssrf_blocks_invalid_schemes() {
562        assert!(validate_url_for_ssrf("file:///etc/passwd").is_err());
563        assert!(validate_url_for_ssrf("ftp://example.com/").is_err());
564        assert!(validate_url_for_ssrf("gopher://example.com/").is_err());
565    }
566
567    #[test]
568    fn test_ssrf_allows_public_urls() {
569        // These should be allowed (public IPs)
570        assert!(validate_url_for_ssrf("https://example.com/").is_ok());
571        assert!(validate_url_for_ssrf("https://httpbin.org/get").is_ok());
572        assert!(validate_url_for_ssrf("http://8.8.8.8/").is_ok());
573    }
574
575    #[test]
576    fn test_dangerous_ipv4() {
577        use std::net::Ipv4Addr;
578
579        // Loopback
580        assert!(is_dangerous_ipv4(Ipv4Addr::new(127, 0, 0, 1)));
581        assert!(is_dangerous_ipv4(Ipv4Addr::new(127, 1, 2, 3)));
582
583        // Private 10.x.x.x
584        assert!(is_dangerous_ipv4(Ipv4Addr::new(10, 0, 0, 1)));
585        assert!(is_dangerous_ipv4(Ipv4Addr::new(10, 255, 255, 255)));
586
587        // Private 172.16-31.x.x
588        assert!(is_dangerous_ipv4(Ipv4Addr::new(172, 16, 0, 1)));
589        assert!(is_dangerous_ipv4(Ipv4Addr::new(172, 31, 255, 255)));
590        assert!(!is_dangerous_ipv4(Ipv4Addr::new(172, 15, 0, 1))); // Not private
591        assert!(!is_dangerous_ipv4(Ipv4Addr::new(172, 32, 0, 1))); // Not private
592
593        // Private 192.168.x.x
594        assert!(is_dangerous_ipv4(Ipv4Addr::new(192, 168, 0, 1)));
595        assert!(is_dangerous_ipv4(Ipv4Addr::new(192, 168, 255, 255)));
596
597        // Link-local (cloud metadata)
598        assert!(is_dangerous_ipv4(Ipv4Addr::new(169, 254, 169, 254)));
599
600        // Public IPs - should NOT be dangerous
601        assert!(!is_dangerous_ipv4(Ipv4Addr::new(8, 8, 8, 8)));
602        assert!(!is_dangerous_ipv4(Ipv4Addr::new(1, 1, 1, 1)));
603        assert!(!is_dangerous_ipv4(Ipv4Addr::new(93, 184, 216, 34)));
604    }
605
606    #[test]
607    fn test_dangerous_ipv6() {
608        use std::net::Ipv6Addr;
609
610        // Loopback
611        assert!(is_dangerous_ipv6(Ipv6Addr::LOCALHOST));
612
613        // Link-local fe80::/10
614        assert!(is_dangerous_ipv6(Ipv6Addr::new(
615            0xfe80, 0, 0, 0, 0, 0, 0, 1
616        )));
617
618        // Unique local fc00::/7
619        assert!(is_dangerous_ipv6(Ipv6Addr::new(
620            0xfc00, 0, 0, 0, 0, 0, 0, 1
621        )));
622        assert!(is_dangerous_ipv6(Ipv6Addr::new(
623            0xfd00, 0, 0, 0, 0, 0, 0, 1
624        )));
625
626        // Public - should NOT be dangerous
627        assert!(!is_dangerous_ipv6(Ipv6Addr::new(
628            0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
629        ))); // Google DNS
630    }
631}