Skip to main content

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_bytes, 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.
202///
203/// `body` is the raw response payload — HTTP bodies are arbitrary
204/// octets per RFC 7230, so we store them in a byte-clean SeqString
205/// without UTF-8 validation. Seq programs that need text decode
206/// the bytes themselves; programs handling binary downloads keep
207/// the original bytes intact.
208fn build_response_map(status: i64, body: Vec<u8>, ok: bool, error: Option<String>) -> Value {
209    let mut map: HashMap<MapKey, Value> = HashMap::new();
210
211    map.insert(
212        MapKey::String(global_string("status".to_string())),
213        Value::Int(status),
214    );
215    map.insert(
216        MapKey::String(global_string("body".to_string())),
217        Value::String(global_bytes(body)),
218    );
219    map.insert(
220        MapKey::String(global_string("ok".to_string())),
221        Value::Bool(ok),
222    );
223
224    if let Some(err) = error {
225        map.insert(
226            MapKey::String(global_string("error".to_string())),
227            Value::String(global_string(err)),
228        );
229    }
230
231    Value::Map(Box::new(map))
232}
233
234/// Build an error response map
235fn error_response(error: String) -> Value {
236    build_response_map(0, Vec::new(), false, Some(error))
237}
238
239/// Perform HTTP GET request
240///
241/// Stack effect: ( url -- response )
242///
243/// Returns a Map with status, body, ok, and optionally error.
244///
245/// # Safety
246/// Stack must have a String (URL) on top
247#[unsafe(no_mangle)]
248pub unsafe extern "C" fn patch_seq_http_get(stack: Stack) -> Stack {
249    assert!(!stack.is_null(), "http.get: stack is empty");
250
251    let (stack, url_value) = unsafe { pop(stack) };
252
253    match url_value {
254        Value::String(url) => {
255            let response = perform_get(url.as_str_or_empty());
256            unsafe { push(stack, response) }
257        }
258        _ => panic!(
259            "http.get: expected String (URL) on stack, got {:?}",
260            url_value
261        ),
262    }
263}
264
265/// Perform HTTP POST request
266///
267/// Stack effect: ( url body content-type -- response )
268///
269/// Returns a Map with status, body, ok, and optionally error.
270///
271/// # Safety
272/// Stack must have three String values on top (url, body, content-type)
273#[unsafe(no_mangle)]
274pub unsafe extern "C" fn patch_seq_http_post(stack: Stack) -> Stack {
275    assert!(!stack.is_null(), "http.post: stack is empty");
276
277    let (stack, content_type_value) = unsafe { pop(stack) };
278    let (stack, body_value) = unsafe { pop(stack) };
279    let (stack, url_value) = unsafe { pop(stack) };
280
281    match (url_value, body_value, content_type_value) {
282        (Value::String(url), Value::String(body), Value::String(content_type)) => {
283            // Body is byte-clean; URL and Content-Type stay text.
284            let response = perform_post(
285                url.as_str_or_empty(),
286                body.as_bytes(),
287                content_type.as_str_or_empty(),
288            );
289            unsafe { push(stack, response) }
290        }
291        (url, body, ct) => panic!(
292            "http.post: expected (String, String, String) on stack, got ({:?}, {:?}, {:?})",
293            url, body, ct
294        ),
295    }
296}
297
298/// Perform HTTP PUT request
299///
300/// Stack effect: ( url body content-type -- response )
301///
302/// Returns a Map with status, body, ok, and optionally error.
303///
304/// # Safety
305/// Stack must have three String values on top (url, body, content-type)
306#[unsafe(no_mangle)]
307pub unsafe extern "C" fn patch_seq_http_put(stack: Stack) -> Stack {
308    assert!(!stack.is_null(), "http.put: stack is empty");
309
310    let (stack, content_type_value) = unsafe { pop(stack) };
311    let (stack, body_value) = unsafe { pop(stack) };
312    let (stack, url_value) = unsafe { pop(stack) };
313
314    match (url_value, body_value, content_type_value) {
315        (Value::String(url), Value::String(body), Value::String(content_type)) => {
316            // Body is byte-clean (see http.post); URL and Content-Type stay text.
317            let response = perform_put(
318                url.as_str_or_empty(),
319                body.as_bytes(),
320                content_type.as_str_or_empty(),
321            );
322            unsafe { push(stack, response) }
323        }
324        (url, body, ct) => panic!(
325            "http.put: expected (String, String, String) on stack, got ({:?}, {:?}, {:?})",
326            url, body, ct
327        ),
328    }
329}
330
331/// Perform HTTP DELETE request
332///
333/// Stack effect: ( url -- response )
334///
335/// Returns a Map with status, body, ok, and optionally error.
336///
337/// # Safety
338/// Stack must have a String (URL) on top
339#[unsafe(no_mangle)]
340pub unsafe extern "C" fn patch_seq_http_delete(stack: Stack) -> Stack {
341    assert!(!stack.is_null(), "http.delete: stack is empty");
342
343    let (stack, url_value) = unsafe { pop(stack) };
344
345    match url_value {
346        Value::String(url) => {
347            let response = perform_delete(url.as_str_or_empty());
348            unsafe { push(stack, response) }
349        }
350        _ => panic!(
351            "http.delete: expected String (URL) on stack, got {:?}",
352            url_value
353        ),
354    }
355}
356
357/// Read up to `MAX_BODY_SIZE` bytes from a ureq response. Returns the
358/// raw byte buffer on success — callers wrap it in a byte-clean
359/// SeqString so binary response bodies (image downloads, Protobuf,
360/// MessagePack, etc.) round-trip intact.
361fn read_response_bytes(response: ureq::Response) -> Result<Vec<u8>, std::io::Error> {
362    use std::io::Read;
363    let mut reader = response.into_reader().take((MAX_BODY_SIZE as u64) + 1);
364    let mut buf = Vec::new();
365    reader.read_to_end(&mut buf)?;
366    Ok(buf)
367}
368
369/// Handle HTTP response result and convert to Value
370fn handle_response(result: Result<ureq::Response, ureq::Error>) -> Value {
371    match result {
372        Ok(response) => {
373            let status = response.status() as i64;
374            let ok = (200..300).contains(&response.status());
375
376            match read_response_bytes(response) {
377                Ok(body) => {
378                    if body.len() > MAX_BODY_SIZE {
379                        error_response(format!(
380                            "Response body too large ({} bytes, max {})",
381                            body.len(),
382                            MAX_BODY_SIZE
383                        ))
384                    } else {
385                        build_response_map(status, body, ok, None)
386                    }
387                }
388                Err(e) => error_response(format!("Failed to read response body: {}", e)),
389            }
390        }
391        Err(ureq::Error::Status(code, response)) => {
392            // HTTP error status (4xx, 5xx) — body might still be useful.
393            let body = read_response_bytes(response).unwrap_or_default();
394            build_response_map(
395                code as i64,
396                body,
397                false,
398                Some(format!("HTTP error: {}", code)),
399            )
400        }
401        Err(ureq::Error::Transport(e)) => {
402            // Connection/transport error
403            error_response(format!("Connection error: {}", e))
404        }
405    }
406}
407
408/// Internal: Perform GET request
409fn perform_get(url: &str) -> Value {
410    // SSRF protection: validate URL before making request
411    if let Err(msg) = validate_url_for_ssrf(url) {
412        return error_response(msg);
413    }
414    handle_response(HTTP_AGENT.get(url).call())
415}
416
417/// Internal: Perform POST request. Body is byte-clean — HTTP request
418/// bodies are arbitrary octets per RFC 7230, so binary content
419/// (Protobuf, MessagePack, image uploads) flows through unchanged.
420fn perform_post(url: &str, body: &[u8], content_type: &str) -> Value {
421    // SSRF protection: validate URL before making request
422    if let Err(msg) = validate_url_for_ssrf(url) {
423        return error_response(msg);
424    }
425    handle_response(
426        HTTP_AGENT
427            .post(url)
428            .set("Content-Type", content_type)
429            .send_bytes(body),
430    )
431}
432
433/// Internal: Perform PUT request. Body is byte-clean (see `perform_post`).
434fn perform_put(url: &str, body: &[u8], content_type: &str) -> Value {
435    // SSRF protection: validate URL before making request
436    if let Err(msg) = validate_url_for_ssrf(url) {
437        return error_response(msg);
438    }
439    handle_response(
440        HTTP_AGENT
441            .put(url)
442            .set("Content-Type", content_type)
443            .send_bytes(body),
444    )
445}
446
447/// Internal: Perform DELETE request
448fn perform_delete(url: &str) -> Value {
449    // SSRF protection: validate URL before making request
450    if let Err(msg) = validate_url_for_ssrf(url) {
451        return error_response(msg);
452    }
453    handle_response(HTTP_AGENT.delete(url).call())
454}
455
456#[cfg(test)]
457mod tests;