Skip to main content

runx_runtime/http/
mod.rs

1// rust-style-allow: large-file because the runtime HTTP transport keeps
2// reqwest wiring, SSRF guards, response limits, and security-focused unit tests
3// in one review unit.
4mod types;
5
6#[cfg(feature = "async-http")]
7use std::error::Error as StdError;
8#[cfg(feature = "async-http")]
9use std::fmt;
10#[cfg(feature = "async-http")]
11use std::net::SocketAddr;
12#[cfg(any(feature = "async-http", test))]
13use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
14#[cfg(feature = "async-http")]
15use std::time::Duration;
16
17#[cfg(any(feature = "async-http", test))]
18use url::Url;
19
20pub use self::types::{
21    HttpMethod, ReqwestHttpTransport, RuntimeHttpHeader, RuntimeHttpRequest, RuntimeHttpResponse,
22    RuntimeHttpTransport,
23};
24
25#[cfg(feature = "async-http")]
26const MAX_HTTP_RESPONSE_BYTES: usize = 1024 * 1024;
27#[cfg(feature = "async-http")]
28const DEFAULT_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
29#[cfg(feature = "async-http")]
30const DEFAULT_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
31#[cfg(feature = "async-http")]
32const MANAGED_AGENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(180);
33
34/// The default browser User-Agent the governed fetch transport presents (current
35/// stable Chrome). Overridable per run with `RUNX_HTTP_USER_AGENT`, opt-out with
36/// `RUNX_HTTP_BROWSER=0`. This is header/UA-level emulation only: it clears basic bot
37/// scoring (a missing UA, no browser headers), NOT TLS (JA3/JA4) or HTTP/2
38/// fingerprinting, which would need a Chrome-impersonating TLS stack and is
39/// deliberately out of scope. Sites on a JS/managed challenge, or that fingerprint the
40/// rustls handshake, are expected to still block us; we surface that as a non-2xx
41/// rather than escalate.
42#[allow(dead_code)] // consumed by the feature-gated http adapter and the transport tests
43pub const DEFAULT_BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
44
45/// The Chrome navigation header set, applied as client default headers so a per-request
46/// (manifest/caller) header of the same name still overrides it. The User-Agent is set
47/// via the builder's `.user_agent()` and Accept-Encoding is owned by the gzip/brotli/...
48/// decoders, so neither is here. reqwest's HeaderMap is hash-ordered and will not
49/// reproduce Chrome's header order on the wire: values match Chrome, order does not,
50/// which is the honest ceiling for header-level emulation.
51#[cfg(feature = "async-http")]
52fn chrome_default_headers() -> reqwest::header::HeaderMap {
53    use reqwest::header::{HeaderMap, HeaderValue};
54    let mut headers = HeaderMap::new();
55    headers.insert(
56        "sec-ch-ua",
57        HeaderValue::from_static(
58            "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not/A)Brand\";v=\"24\"",
59        ),
60    );
61    headers.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
62    headers.insert(
63        "sec-ch-ua-platform",
64        HeaderValue::from_static("\"Windows\""),
65    );
66    headers.insert("upgrade-insecure-requests", HeaderValue::from_static("1"));
67    headers.insert(
68        "accept",
69        HeaderValue::from_static(
70            "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
71        ),
72    );
73    headers.insert("sec-fetch-site", HeaderValue::from_static("none"));
74    headers.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
75    headers.insert("sec-fetch-user", HeaderValue::from_static("?1"));
76    headers.insert("sec-fetch-dest", HeaderValue::from_static("document"));
77    headers.insert(
78        "accept-language",
79        HeaderValue::from_static("en-US,en;q=0.9"),
80    );
81    headers.insert("priority", HeaderValue::from_static("u=0, i"));
82    headers
83}
84
85#[cfg(feature = "async-http")]
86impl ReqwestHttpTransport {
87    pub fn new() -> Result<Self, RuntimeHttpError> {
88        Self::with_timeouts_and_private_networks(
89            DEFAULT_HTTP_REQUEST_TIMEOUT,
90            DEFAULT_HTTP_CONNECT_TIMEOUT,
91            false,
92            None,
93        )
94    }
95
96    fn with_timeouts_and_private_networks(
97        request_timeout: Duration,
98        connect_timeout: Duration,
99        allow_private_networks: bool,
100        browser_user_agent: Option<String>,
101    ) -> Result<Self, RuntimeHttpError> {
102        // reqwest is built with `rustls-no-provider`, so the process needs a
103        // default crypto provider before a TLS client can be constructed.
104        // Install ring once; an Err means another transport already set it.
105        let _ = rustls::crypto::ring::default_provider().install_default();
106        // Decode like a browser (the decoders also advertise the matching
107        // Accept-Encoding) and let ALPN negotiate HTTP/2; a no-compression,
108        // http1-only client is a bot tell. The response cap measures DECODED
109        // bytes (read_limited_response_body), so a decompression bomb stays bounded.
110        let mut builder = reqwest::Client::builder()
111            .redirect(reqwest::redirect::Policy::none())
112            .timeout(request_timeout)
113            .connect_timeout(connect_timeout)
114            .gzip(true)
115            .brotli(true)
116            .deflate(true)
117            .zstd(true);
118        // The browser profile is a default-header layer: a per-request
119        // (manifest/caller) header of the same name still overrides it. The UA goes
120        // through the dedicated builder method so a caller UA header overrides it
121        // without duplicating. None = the plain client (internal/API callers).
122        if let Some(user_agent) = browser_user_agent {
123            builder = builder
124                .user_agent(user_agent)
125                .default_headers(chrome_default_headers());
126        }
127        if !allow_private_networks {
128            builder = builder.dns_resolver(GuardedDnsResolver::new(TokioDnsResolver));
129        }
130        let client = builder
131            .build()
132            .map_err(|error| RuntimeHttpError::Transport {
133                message: transport_error_message(&error),
134            })?;
135        Ok(Self {
136            client,
137            allow_private_networks,
138        })
139    }
140
141    /// Build a transport that may reach private or loopback networks. This is the
142    /// explicit, opt-in escape from the default SSRF/private-network block; callers
143    /// must require an operator-declared opt-in (e.g. an `http` source's
144    /// `allowPrivateNetwork`) before choosing it, never as a default.
145    pub fn with_private_network_access() -> Result<Self, RuntimeHttpError> {
146        Self::with_timeouts_and_private_networks(
147            DEFAULT_HTTP_REQUEST_TIMEOUT,
148            DEFAULT_HTTP_CONNECT_TIMEOUT,
149            true,
150            None,
151        )
152    }
153
154    /// Build the model-provider transport for managed-agent calls. These calls can
155    /// legitimately take longer than the generic governed HTTP timeout while the
156    /// provider thinks and emits tool use, but they still keep the same public-DNS
157    /// guard and short connect timeout.
158    pub fn for_managed_agent() -> Result<Self, RuntimeHttpError> {
159        Self::with_timeouts_and_private_networks(
160            MANAGED_AGENT_REQUEST_TIMEOUT,
161            DEFAULT_HTTP_CONNECT_TIMEOUT,
162            false,
163            None,
164        )
165    }
166
167    /// Build the open-web fetch transport: the optional browser profile (a
168    /// `Some(user_agent)` enables it; `None` is the plain client) plus the
169    /// private-network flag. The `http` skill adapter uses this; `new()` and
170    /// `with_private_network_access()` stay plain for internal/API callers (the
171    /// agent transport, the registry) where a browser profile does not belong.
172    pub fn with_options(
173        allow_private_networks: bool,
174        browser_user_agent: Option<String>,
175    ) -> Result<Self, RuntimeHttpError> {
176        Self::with_timeouts_and_private_networks(
177            DEFAULT_HTTP_REQUEST_TIMEOUT,
178            DEFAULT_HTTP_CONNECT_TIMEOUT,
179            allow_private_networks,
180            browser_user_agent,
181        )
182    }
183
184    #[cfg(test)]
185    fn with_private_network_access_for_tests() -> Result<Self, RuntimeHttpError> {
186        Self::with_private_network_access()
187    }
188
189    #[cfg(test)]
190    fn with_private_network_timeouts_for_tests(
191        request_timeout: Duration,
192        connect_timeout: Duration,
193    ) -> Result<Self, RuntimeHttpError> {
194        Self::with_timeouts_and_private_networks(request_timeout, connect_timeout, true, None)
195    }
196}
197
198#[cfg(feature = "async-http")]
199impl RuntimeHttpTransport for ReqwestHttpTransport {
200    fn send(&self, request: RuntimeHttpRequest) -> Result<RuntimeHttpResponse, RuntimeHttpError> {
201        validate_http_url(&request.url, self.allow_private_networks)?;
202        let client = self.client.clone();
203        block_on_http(async move {
204            let method = reqwest_method(request.method);
205            let mut builder = client.request(method, request.url);
206            for header in request.headers {
207                validate_header(&header)?;
208                let name = reqwest::header::HeaderName::from_bytes(header.name.trim().as_bytes())
209                    .map_err(|error| RuntimeHttpError::InvalidHeaderName {
210                    name: header.name.clone(),
211                    message: error.to_string(),
212                })?;
213                let value =
214                    reqwest::header::HeaderValue::from_str(&header.value).map_err(|error| {
215                        RuntimeHttpError::InvalidHeaderValue {
216                            name: header.name.clone(),
217                            message: error.to_string(),
218                        }
219                    })?;
220                builder = builder.header(name, value);
221            }
222            if let Some(body) = request.body {
223                builder = builder.body(body);
224            }
225            let response = builder
226                .send()
227                .await
228                .map_err(|error| RuntimeHttpError::Transport {
229                    message: transport_error_message(&error),
230                })?;
231            let status = response.status().as_u16();
232            let body = read_limited_response_body(response, MAX_HTTP_RESPONSE_BYTES).await?;
233            Ok(RuntimeHttpResponse { status, body })
234        })
235    }
236}
237
238#[cfg(feature = "async-http")]
239fn transport_error_message(error: &(dyn StdError + 'static)) -> String {
240    let mut parts = vec![error.to_string()];
241    let mut source = error.source();
242    while let Some(error) = source {
243        parts.push(error.to_string());
244        source = error.source();
245    }
246    parts.dedup();
247    parts.join(": ")
248}
249
250#[cfg(feature = "async-http")]
251#[derive(Clone, Debug)]
252struct GuardedDnsResolver<R> {
253    inner: R,
254}
255
256#[cfg(feature = "async-http")]
257impl<R> GuardedDnsResolver<R> {
258    fn new(inner: R) -> Self {
259        Self { inner }
260    }
261}
262
263#[cfg(feature = "async-http")]
264impl<R> reqwest::dns::Resolve for GuardedDnsResolver<R>
265where
266    R: reqwest::dns::Resolve + Clone + Send + Sync + 'static,
267{
268    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
269        let host = name.as_str().to_owned();
270        let inner = self.inner.clone();
271        Box::pin(async move {
272            let addrs = inner.resolve(name).await?;
273            let mut public_addrs = Vec::new();
274            for addr in addrs {
275                if is_private_network_ip(addr.ip()) {
276                    return Err(PrivateDnsResolutionError { host, addr }.into());
277                }
278                public_addrs.push(addr);
279            }
280            if public_addrs.is_empty() {
281                return Err(EmptyDnsResolutionError { host }.into());
282            }
283            Ok(Box::new(public_addrs.into_iter()) as reqwest::dns::Addrs)
284        })
285    }
286}
287
288#[cfg(feature = "async-http")]
289#[derive(Clone, Copy, Debug, Default)]
290struct TokioDnsResolver;
291
292#[cfg(feature = "async-http")]
293impl reqwest::dns::Resolve for TokioDnsResolver {
294    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
295        let host = name.as_str().to_owned();
296        Box::pin(async move {
297            let addrs = tokio::net::lookup_host((host.as_str(), 0))
298                .await
299                .map_err(|error| Box::new(error) as Box<dyn StdError + Send + Sync>)?;
300            let addrs = addrs.collect::<Vec<_>>();
301            Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
302        })
303    }
304}
305
306#[cfg(feature = "async-http")]
307#[derive(Debug)]
308struct PrivateDnsResolutionError {
309    host: String,
310    addr: SocketAddr,
311}
312
313#[cfg(feature = "async-http")]
314impl fmt::Display for PrivateDnsResolutionError {
315    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316        write!(
317            formatter,
318            "runtime HTTP DNS resolved '{}' to non-public address {}",
319            self.host, self.addr
320        )
321    }
322}
323
324#[cfg(feature = "async-http")]
325impl StdError for PrivateDnsResolutionError {}
326
327#[cfg(feature = "async-http")]
328#[derive(Debug)]
329struct EmptyDnsResolutionError {
330    host: String,
331}
332
333#[cfg(feature = "async-http")]
334impl fmt::Display for EmptyDnsResolutionError {
335    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336        write!(
337            formatter,
338            "runtime HTTP DNS returned no addresses for '{}'",
339            self.host
340        )
341    }
342}
343
344#[cfg(feature = "async-http")]
345impl StdError for EmptyDnsResolutionError {}
346
347#[derive(Clone, Debug)]
348#[cfg(any(feature = "async-http", test))]
349#[allow(dead_code)]
350pub struct RuntimeHttpClient<T = ReqwestHttpTransport> {
351    base_url: String,
352    transport: T,
353}
354
355#[cfg(any(feature = "async-http", test))]
356#[allow(dead_code)]
357impl<T: RuntimeHttpTransport> RuntimeHttpClient<T> {
358    pub fn with_transport(
359        base_url: impl AsRef<str>,
360        transport: T,
361    ) -> Result<Self, RuntimeHttpError> {
362        let base_url = strip_one_trailing_slash(base_url.as_ref());
363        validate_http_url(&base_url, false)?;
364        Ok(Self {
365            base_url,
366            transport,
367        })
368    }
369
370    pub fn route_url(&self, route: &str) -> Result<String, RuntimeHttpError> {
371        let normalized_route = route.trim_start_matches('/');
372        let url = format!("{}/{}", self.base_url, normalized_route);
373        validate_http_url(&url, false)?;
374        Ok(url)
375    }
376
377    pub fn request(
378        &self,
379        method: HttpMethod,
380        route: &str,
381    ) -> Result<RuntimeHttpRequest, RuntimeHttpError> {
382        Ok(RuntimeHttpRequest {
383            method,
384            url: self.route_url(route)?,
385            headers: Vec::new(),
386            body: None,
387        })
388    }
389
390    pub fn send(
391        &self,
392        request: RuntimeHttpRequest,
393    ) -> Result<RuntimeHttpResponse, RuntimeHttpError> {
394        self.transport.send(request)
395    }
396}
397
398#[derive(Debug, thiserror::Error)]
399pub enum RuntimeHttpError {
400    #[error("invalid runtime HTTP url: {0}")]
401    InvalidUrl(#[from] url::ParseError),
402    #[error("runtime HTTP transport failed: {message}")]
403    Transport { message: String },
404    #[error("runtime HTTP transport cannot block inside an active async runtime")]
405    BlockingHttpInsideAsyncRuntime,
406    #[error("runtime HTTP async runtime is unavailable: {message}")]
407    AsyncRuntimeUnavailable { message: String },
408    #[error("runtime HTTP transport returned invalid output: {message}")]
409    TransportDecode { message: String },
410    #[error("runtime HTTP response body exceeds {limit} byte limit")]
411    ResponseBodyTooLarge { limit: usize },
412    #[error("unsupported runtime HTTP url scheme '{scheme}': only http and https are allowed")]
413    UnsupportedUrlScheme { scheme: String },
414    #[error("runtime HTTP url host '{host}' is not publicly routable")]
415    PrivateNetworkUrl { host: String },
416    #[error("invalid runtime HTTP header name '{name}': {message}")]
417    InvalidHeaderName { name: String, message: String },
418    #[error("invalid runtime HTTP header value for '{name}': {message}")]
419    InvalidHeaderValue { name: String, message: String },
420}
421
422pub(crate) fn strip_one_trailing_slash(value: &str) -> String {
423    value.strip_suffix('/').unwrap_or(value).to_owned()
424}
425
426#[cfg(feature = "async-http")]
427fn validate_header(header: &RuntimeHttpHeader) -> Result<(), RuntimeHttpError> {
428    let name = header.name.trim();
429    if name.is_empty() || !name.bytes().all(is_header_token_byte) {
430        return Err(RuntimeHttpError::InvalidHeaderName {
431            name: header.name.clone(),
432            message: "header names must be HTTP token characters".to_owned(),
433        });
434    }
435    if header.value.contains('\r') || header.value.contains('\n') {
436        return Err(RuntimeHttpError::InvalidHeaderValue {
437            name: header.name.clone(),
438            message: "header values must not contain line breaks".to_owned(),
439        });
440    }
441    Ok(())
442}
443
444#[cfg(any(feature = "async-http", test))]
445#[allow(dead_code)]
446fn validate_http_url(value: &str, allow_private_networks: bool) -> Result<(), RuntimeHttpError> {
447    let url = Url::parse(value)?;
448    match url.scheme() {
449        "http" | "https" => validate_public_host(&url, allow_private_networks),
450        scheme => Err(RuntimeHttpError::UnsupportedUrlScheme {
451            scheme: scheme.to_owned(),
452        }),
453    }
454}
455
456#[cfg(any(feature = "async-http", test))]
457fn validate_public_host(url: &Url, allow_private_networks: bool) -> Result<(), RuntimeHttpError> {
458    if allow_private_networks {
459        return Ok(());
460    }
461    let Some(host) = url.host_str() else {
462        return Err(RuntimeHttpError::PrivateNetworkUrl {
463            host: "<missing>".to_owned(),
464        });
465    };
466    let normalized = host.trim_end_matches('.').to_ascii_lowercase();
467    if normalized == "localhost"
468        || normalized.ends_with(".localhost")
469        || normalized == "metadata.google.internal"
470    {
471        return Err(RuntimeHttpError::PrivateNetworkUrl {
472            host: host.to_owned(),
473        });
474    }
475    let ip_host = normalized
476        .strip_prefix('[')
477        .and_then(|value| value.strip_suffix(']'))
478        .unwrap_or(&normalized);
479    if let Ok(ip) = ip_host.parse::<IpAddr>() {
480        if is_private_network_ip(ip) {
481            return Err(RuntimeHttpError::PrivateNetworkUrl {
482                host: host.to_owned(),
483            });
484        }
485    }
486    Ok(())
487}
488
489#[cfg(any(feature = "async-http", test))]
490fn is_private_network_ip(ip: IpAddr) -> bool {
491    match ip {
492        IpAddr::V4(ip) => is_private_network_ipv4(ip),
493        IpAddr::V6(ip) => is_private_network_ipv6(ip),
494    }
495}
496
497#[cfg(any(feature = "async-http", test))]
498fn is_private_network_ipv4(ip: Ipv4Addr) -> bool {
499    let octets = ip.octets();
500    ip.is_private()
501        || ip.is_loopback()
502        || ip.is_link_local()
503        || ip.is_broadcast()
504        || ip.is_documentation()
505        || ip.is_unspecified()
506        || ip.is_multicast()
507        || octets[0] == 0
508        || (octets[0] == 100 && (octets[1] & 0xc0) == 0x40)
509        || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
510        || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19))
511        || octets[0] >= 240
512        || octets == [169, 254, 169, 254]
513}
514
515#[cfg(any(feature = "async-http", test))]
516fn is_private_network_ipv6(ip: Ipv6Addr) -> bool {
517    ip.to_ipv4_mapped().is_some_and(is_private_network_ipv4)
518        || ip.is_loopback()
519        || ip.is_unspecified()
520        || ip.is_multicast()
521        || is_unique_local_ipv6(ip)
522        || is_unicast_link_local_ipv6(ip)
523        || is_documentation_ipv6(ip)
524        || nat64_embedded_ipv4(ip).is_some_and(is_private_network_ipv4)
525        || six_to_four_embedded_ipv4(ip).is_some_and(is_private_network_ipv4)
526}
527
528#[cfg(any(feature = "async-http", test))]
529fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
530    (ip.segments()[0] & 0xfe00) == 0xfc00
531}
532
533#[cfg(any(feature = "async-http", test))]
534fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool {
535    (ip.segments()[0] & 0xffc0) == 0xfe80
536}
537
538#[cfg(any(feature = "async-http", test))]
539fn is_documentation_ipv6(ip: Ipv6Addr) -> bool {
540    ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8
541}
542
543#[cfg(any(feature = "async-http", test))]
544fn nat64_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
545    let segments = ip.segments();
546    if segments[..6] != [0x0064, 0xff9b, 0, 0, 0, 0] {
547        return None;
548    }
549    Some(Ipv4Addr::new(
550        (segments[6] >> 8) as u8,
551        segments[6] as u8,
552        (segments[7] >> 8) as u8,
553        segments[7] as u8,
554    ))
555}
556
557#[cfg(any(feature = "async-http", test))]
558fn six_to_four_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
559    let segments = ip.segments();
560    if segments[0] != 0x2002 {
561        return None;
562    }
563    Some(Ipv4Addr::new(
564        (segments[1] >> 8) as u8,
565        segments[1] as u8,
566        (segments[2] >> 8) as u8,
567        segments[2] as u8,
568    ))
569}
570
571#[cfg(feature = "async-http")]
572async fn read_limited_response_body(
573    mut response: reqwest::Response,
574    limit: usize,
575) -> Result<String, RuntimeHttpError> {
576    if declared_response_length(&response)?.is_some_and(|length| length > limit as u64) {
577        return Err(RuntimeHttpError::ResponseBodyTooLarge { limit });
578    }
579    let mut body = Vec::new();
580    while let Some(chunk) =
581        response
582            .chunk()
583            .await
584            .map_err(|error| RuntimeHttpError::TransportDecode {
585                message: error.to_string(),
586            })?
587    {
588        if body.len().saturating_add(chunk.len()) > limit {
589            return Err(RuntimeHttpError::ResponseBodyTooLarge { limit });
590        }
591        body.extend_from_slice(&chunk);
592    }
593    Ok(String::from_utf8_lossy(&body).into_owned())
594}
595
596#[cfg(feature = "async-http")]
597fn declared_response_length(response: &reqwest::Response) -> Result<Option<u64>, RuntimeHttpError> {
598    let Some(value) = response.headers().get(reqwest::header::CONTENT_LENGTH) else {
599        return Ok(response.content_length());
600    };
601    let value = value
602        .to_str()
603        .map_err(|error| RuntimeHttpError::TransportDecode {
604            message: format!("invalid Content-Length header: {error}"),
605        })?;
606    value
607        .parse::<u64>()
608        .map(Some)
609        .map_err(|error| RuntimeHttpError::TransportDecode {
610            message: format!("invalid Content-Length header: {error}"),
611        })
612}
613
614#[cfg(feature = "async-http")]
615fn reqwest_method(method: HttpMethod) -> reqwest::Method {
616    match method {
617        HttpMethod::Get => reqwest::Method::GET,
618        HttpMethod::Post => reqwest::Method::POST,
619        HttpMethod::Put => reqwest::Method::PUT,
620        HttpMethod::Patch => reqwest::Method::PATCH,
621        HttpMethod::Delete => reqwest::Method::DELETE,
622    }
623}
624
625#[cfg(feature = "async-http")]
626fn block_on_http<F, T>(future: F) -> Result<T, RuntimeHttpError>
627where
628    F: std::future::Future<Output = Result<T, RuntimeHttpError>>,
629{
630    if tokio::runtime::Handle::try_current().is_ok() {
631        return Err(RuntimeHttpError::BlockingHttpInsideAsyncRuntime);
632    }
633    let runtime = tokio::runtime::Builder::new_current_thread()
634        .enable_all()
635        .build()
636        .map_err(|error| RuntimeHttpError::AsyncRuntimeUnavailable {
637            message: error.to_string(),
638        })?;
639    runtime.block_on(future)
640}
641
642#[cfg(feature = "async-http")]
643fn is_header_token_byte(byte: u8) -> bool {
644    byte.is_ascii_alphanumeric()
645        || matches!(
646            byte,
647            b'!' | b'#'
648                | b'$'
649                | b'%'
650                | b'&'
651                | b'\''
652                | b'*'
653                | b'+'
654                | b'-'
655                | b'.'
656                | b'^'
657                | b'_'
658                | b'`'
659                | b'|'
660                | b'~'
661        )
662}
663
664#[cfg(test)]
665mod tests {
666    use std::cell::RefCell;
667    use std::io;
668    #[cfg(feature = "async-http")]
669    use std::io::{Read, Write};
670    #[cfg(feature = "async-http")]
671    use std::net::TcpListener;
672    #[cfg(feature = "async-http")]
673    use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
674    #[cfg(feature = "async-http")]
675    use std::time::Duration;
676
677    #[cfg(feature = "async-http")]
678    use super::{GuardedDnsResolver, MAX_HTTP_RESPONSE_BYTES, ReqwestHttpTransport, block_on_http};
679    use super::{
680        HttpMethod, RuntimeHttpClient, RuntimeHttpError, RuntimeHttpHeader, RuntimeHttpRequest,
681        RuntimeHttpResponse, RuntimeHttpTransport,
682    };
683    #[cfg(feature = "async-http")]
684    use reqwest::dns::Resolve as _;
685
686    #[derive(Default)]
687    struct MockTransport {
688        requests: RefCell<Vec<RuntimeHttpRequest>>,
689    }
690
691    impl RuntimeHttpTransport for &MockTransport {
692        fn send(
693            &self,
694            request: RuntimeHttpRequest,
695        ) -> Result<RuntimeHttpResponse, RuntimeHttpError> {
696            self.requests.borrow_mut().push(request);
697            Ok(RuntimeHttpResponse {
698                status: 204,
699                body: String::new(),
700            })
701        }
702    }
703
704    #[cfg(feature = "async-http")]
705    #[derive(Clone, Debug)]
706    struct StaticDnsResolver {
707        addrs: Vec<SocketAddr>,
708    }
709
710    #[cfg(feature = "async-http")]
711    impl reqwest::dns::Resolve for StaticDnsResolver {
712        fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving {
713            let addrs = self.addrs.clone();
714            Box::pin(async move { Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs) })
715        }
716    }
717
718    #[derive(Debug, thiserror::Error)]
719    enum RuntimeHttpTestError {
720        #[error(transparent)]
721        RuntimeHttp(#[from] RuntimeHttpError),
722        #[error(transparent)]
723        Io(#[from] io::Error),
724        #[cfg(feature = "async-http")]
725        #[error("server thread panicked")]
726        ServerThread,
727    }
728
729    #[test]
730    fn client_normalizes_base_url_and_routes_requests() -> Result<(), RuntimeHttpTestError> {
731        let transport = MockTransport::default();
732        let client = RuntimeHttpClient::with_transport("https://api.example/", &transport)?;
733
734        let mut request = client.request(HttpMethod::Delete, "/v1/grants/grant_1")?;
735        request
736            .headers
737            .push(RuntimeHttpHeader::new("accept", "application/json"));
738        request.body = Some("{\"ok\":true}".to_owned());
739        let response = client.send(request)?;
740
741        assert_eq!(response.status, 204);
742        let sent = transport.requests.borrow();
743        assert_eq!(sent[0].method, HttpMethod::Delete);
744        assert_eq!(sent[0].url, "https://api.example/v1/grants/grant_1");
745        assert_eq!(sent[0].headers[0].name, "accept");
746        assert_eq!(sent[0].body.as_deref(), Some("{\"ok\":true}"));
747        Ok(())
748    }
749
750    #[test]
751    fn debug_output_redacts_sensitive_header_values() {
752        let request = RuntimeHttpRequest {
753            method: HttpMethod::Get,
754            url: "https://api.example/v1/grants".to_owned(),
755            headers: vec![
756                RuntimeHttpHeader::new("authorization", "Bearer SECRET_RUNTIME_TOKEN"),
757                RuntimeHttpHeader::new("x-runx-token", "SECRET_HEADER_TOKEN"),
758                RuntimeHttpHeader::new("accept", "application/json"),
759            ],
760            body: Some("SECRET_BODY".to_owned()),
761        };
762
763        let debug = format!("{request:?}");
764        assert!(!debug.contains("SECRET_RUNTIME_TOKEN"));
765        assert!(!debug.contains("SECRET_HEADER_TOKEN"));
766        assert!(!debug.contains("SECRET_BODY"));
767        assert!(debug.contains("[redacted]"));
768        assert!(debug.contains("application/json"));
769    }
770
771    #[test]
772    fn invalid_base_urls_fail_closed() {
773        assert!(RuntimeHttpClient::with_transport("not a url", &MockTransport::default()).is_err());
774        assert!(matches!(
775            RuntimeHttpClient::with_transport("file:///tmp/runx.sock", &MockTransport::default()),
776            Err(RuntimeHttpError::UnsupportedUrlScheme { .. })
777        ));
778    }
779
780    #[test]
781    fn private_network_base_urls_fail_closed() {
782        for value in [
783            "http://localhost",
784            "http://service.localhost",
785            "http://127.0.0.1",
786            "http://10.0.0.1",
787            "http://172.16.0.1",
788            "http://192.168.0.1",
789            "http://169.254.169.254",
790            "http://100.64.0.1",
791            "http://100.127.255.255",
792            "http://192.0.0.1",
793            "http://198.18.0.1",
794            "http://240.0.0.1",
795            "http://0.1.2.3",
796            "http://[::1]",
797            "http://[::ffff:127.0.0.1]",
798            "http://[64:ff9b::7f00:1]",
799            "http://[2002:7f00:1::]",
800            "http://[fc00::1]",
801            "http://[fe80::1]",
802            "http://metadata.google.internal",
803        ] {
804            assert!(
805                matches!(
806                    RuntimeHttpClient::with_transport(value, &MockTransport::default()),
807                    Err(RuntimeHttpError::PrivateNetworkUrl { .. })
808                ),
809                "{value} should be rejected as private"
810            );
811        }
812    }
813
814    #[test]
815    fn public_base_urls_are_allowed() -> Result<(), RuntimeHttpTestError> {
816        RuntimeHttpClient::with_transport("https://api.example", &MockTransport::default())?;
817        RuntimeHttpClient::with_transport("http://8.8.8.8", &MockTransport::default())?;
818        RuntimeHttpClient::with_transport("http://[64:ff9b::808:808]", &MockTransport::default())?;
819        Ok(())
820    }
821
822    #[test]
823    #[cfg(feature = "async-http")]
824    fn guarded_dns_resolver_rejects_private_resolved_addresses() -> Result<(), RuntimeHttpTestError>
825    {
826        let resolver = GuardedDnsResolver::new(StaticDnsResolver {
827            addrs: vec![SocketAddr::V4(SocketAddrV4::new(
828                Ipv4Addr::new(127, 0, 0, 1),
829                0,
830            ))],
831        });
832        let name = "public.example"
833            .parse()
834            .map_err(|error| RuntimeHttpError::Transport {
835                message: format!("test DNS name should parse: {error}"),
836            })?;
837        let error =
838            block_on_http(async {
839                resolver.resolve(name).await.map(|_| ()).map_err(|error| {
840                    RuntimeHttpError::Transport {
841                        message: error.to_string(),
842                    }
843                })
844            })
845            .err();
846
847        assert!(
848            matches!(error, Some(RuntimeHttpError::Transport { ref message }) if message.contains("non-public address")),
849            "expected private DNS resolution to fail closed, got: {error:?}"
850        );
851        Ok(())
852    }
853
854    #[test]
855    #[cfg(feature = "async-http")]
856    fn reqwest_transport_does_not_follow_redirects() -> Result<(), RuntimeHttpTestError> {
857        let listener = TcpListener::bind("127.0.0.1:0")?;
858        let address = listener.local_addr()?;
859        let server = std::thread::spawn(move || -> Result<String, std::io::Error> {
860            let (mut stream, _) = listener.accept()?;
861            let mut buffer = [0_u8; 1024];
862            let bytes_read = stream.read(&mut buffer)?;
863            stream.write_all(
864                b"HTTP/1.1 302 Found\r\nLocation: /redirected\r\nContent-Length: 0\r\n\r\n",
865            )?;
866            Ok(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
867        });
868
869        let transport = ReqwestHttpTransport::with_private_network_access_for_tests()?;
870        let response = transport.send(RuntimeHttpRequest {
871            method: HttpMethod::Get,
872            url: format!("http://{address}/start"),
873            headers: Vec::new(),
874            body: None,
875        })?;
876        let request = server
877            .join()
878            .map_err(|_| RuntimeHttpTestError::ServerThread)??;
879
880        assert_eq!(response.status, 302);
881        assert!(request.starts_with("GET /start "));
882        Ok(())
883    }
884
885    #[test]
886    #[cfg(feature = "async-http")]
887    fn reqwest_transport_rejects_header_injection() -> Result<(), RuntimeHttpTestError> {
888        let transport = ReqwestHttpTransport::new()?;
889        let error = transport
890            .send(RuntimeHttpRequest {
891                method: HttpMethod::Get,
892                url: "https://api.example/v1".to_owned(),
893                headers: vec![RuntimeHttpHeader::new("x-runx", "good\nbad")],
894                body: None,
895            })
896            .err();
897        assert!(matches!(
898            error,
899            Some(RuntimeHttpError::InvalidHeaderValue { .. })
900        ));
901        Ok(())
902    }
903
904    #[cfg(feature = "async-http")]
905    #[test]
906    fn reqwest_transport_rejects_non_http_urls_before_sending() -> Result<(), RuntimeHttpTestError>
907    {
908        let transport = ReqwestHttpTransport::new()?;
909        let error = transport
910            .send(RuntimeHttpRequest {
911                method: HttpMethod::Get,
912                url: "file:///etc/passwd".to_owned(),
913                headers: Vec::new(),
914                body: None,
915            })
916            .err();
917
918        assert!(matches!(
919            error,
920            Some(RuntimeHttpError::UnsupportedUrlScheme { .. })
921        ));
922        Ok(())
923    }
924
925    #[cfg(feature = "async-http")]
926    #[test]
927    fn reqwest_transport_rejects_oversized_content_length() -> Result<(), RuntimeHttpTestError> {
928        let listener = TcpListener::bind("127.0.0.1:0")?;
929        let address = listener.local_addr()?;
930        let server = std::thread::spawn(move || -> Result<(), std::io::Error> {
931            let (mut stream, _) = listener.accept()?;
932            let mut buffer = [0_u8; 1024];
933            let _ = stream.read(&mut buffer)?;
934            let response = format!(
935                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
936                MAX_HTTP_RESPONSE_BYTES + 1
937            );
938            stream.write_all(response.as_bytes())?;
939            Ok(())
940        });
941
942        let transport = ReqwestHttpTransport::with_private_network_access_for_tests()?;
943        let error = transport
944            .send(RuntimeHttpRequest {
945                method: HttpMethod::Get,
946                url: format!("http://{address}/too-large"),
947                headers: Vec::new(),
948                body: None,
949            })
950            .err();
951        server
952            .join()
953            .map_err(|_| RuntimeHttpTestError::ServerThread)??;
954
955        assert!(matches!(
956            error,
957            Some(RuntimeHttpError::ResponseBodyTooLarge { limit })
958                if limit == MAX_HTTP_RESPONSE_BYTES
959        ));
960        Ok(())
961    }
962
963    #[cfg(feature = "async-http")]
964    #[test]
965    fn reqwest_transport_caps_streamed_response_body() -> Result<(), RuntimeHttpTestError> {
966        let listener = TcpListener::bind("127.0.0.1:0")?;
967        let address = listener.local_addr()?;
968        let server = std::thread::spawn(move || -> Result<(), std::io::Error> {
969            let (mut stream, _) = listener.accept()?;
970            let mut buffer = [0_u8; 1024];
971            let _ = stream.read(&mut buffer)?;
972            stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n")?;
973            let _ = stream.write_all(&vec![b'a'; MAX_HTTP_RESPONSE_BYTES + 1]);
974            Ok(())
975        });
976
977        let transport = ReqwestHttpTransport::with_private_network_access_for_tests()?;
978        let error = transport
979            .send(RuntimeHttpRequest {
980                method: HttpMethod::Get,
981                url: format!("http://{address}/stream-too-large"),
982                headers: Vec::new(),
983                body: None,
984            })
985            .err();
986        server
987            .join()
988            .map_err(|_| RuntimeHttpTestError::ServerThread)??;
989
990        assert!(matches!(
991            error,
992            Some(RuntimeHttpError::ResponseBodyTooLarge { limit })
993                if limit == MAX_HTTP_RESPONSE_BYTES
994        ));
995        Ok(())
996    }
997
998    #[cfg(feature = "async-http")]
999    #[test]
1000    fn reqwest_transport_times_out_stalled_response() -> Result<(), RuntimeHttpTestError> {
1001        let listener = TcpListener::bind("127.0.0.1:0")?;
1002        let address = listener.local_addr()?;
1003        let server = std::thread::spawn(move || -> Result<(), std::io::Error> {
1004            let (_stream, _) = listener.accept()?;
1005            std::thread::sleep(Duration::from_millis(500));
1006            Ok(())
1007        });
1008
1009        let transport = ReqwestHttpTransport::with_private_network_timeouts_for_tests(
1010            Duration::from_millis(100),
1011            Duration::from_millis(100),
1012        )?;
1013        let error = transport
1014            .send(RuntimeHttpRequest {
1015                method: HttpMethod::Get,
1016                url: format!("http://{address}/stall"),
1017                headers: Vec::new(),
1018                body: None,
1019            })
1020            .err();
1021        server
1022            .join()
1023            .map_err(|_| RuntimeHttpTestError::ServerThread)??;
1024
1025        assert!(matches!(error, Some(RuntimeHttpError::Transport { .. })));
1026        Ok(())
1027    }
1028
1029    #[cfg(feature = "async-http")]
1030    #[test]
1031    fn browser_profile_sends_chrome_ua_and_client_hints() -> Result<(), RuntimeHttpTestError> {
1032        let listener = TcpListener::bind("127.0.0.1:0")?;
1033        let address = listener.local_addr()?;
1034        let server = std::thread::spawn(move || -> Result<String, std::io::Error> {
1035            let (mut stream, _) = listener.accept()?;
1036            let mut buffer = [0_u8; 4096];
1037            let bytes_read = stream.read(&mut buffer)?;
1038            stream.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")?;
1039            Ok(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
1040        });
1041
1042        // with_options(private = true) so the loopback test server is reachable.
1043        let transport = ReqwestHttpTransport::with_options(
1044            true,
1045            Some(super::DEFAULT_BROWSER_USER_AGENT.to_owned()),
1046        )?;
1047        transport.send(RuntimeHttpRequest {
1048            method: HttpMethod::Get,
1049            url: format!("http://{address}/probe"),
1050            headers: Vec::new(),
1051            body: None,
1052        })?;
1053        let request = server
1054            .join()
1055            .map_err(|_| RuntimeHttpTestError::ServerThread)??;
1056
1057        let lower = request.to_ascii_lowercase();
1058        assert!(
1059            lower.contains("chrome/143"),
1060            "browser UA should be sent: {request}"
1061        );
1062        assert!(
1063            lower.contains("sec-ch-ua"),
1064            "client-hint headers should be sent: {request}"
1065        );
1066        assert!(
1067            lower.contains("sec-fetch-mode"),
1068            "fetch-metadata headers should be sent: {request}"
1069        );
1070        Ok(())
1071    }
1072
1073    #[cfg(feature = "async-http")]
1074    #[test]
1075    fn caller_header_overrides_browser_default() -> Result<(), RuntimeHttpTestError> {
1076        let listener = TcpListener::bind("127.0.0.1:0")?;
1077        let address = listener.local_addr()?;
1078        let server = std::thread::spawn(move || -> Result<String, std::io::Error> {
1079            let (mut stream, _) = listener.accept()?;
1080            let mut buffer = [0_u8; 4096];
1081            let bytes_read = stream.read(&mut buffer)?;
1082            stream.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")?;
1083            Ok(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
1084        });
1085
1086        let transport = ReqwestHttpTransport::with_options(
1087            true,
1088            Some(super::DEFAULT_BROWSER_USER_AGENT.to_owned()),
1089        )?;
1090        transport.send(RuntimeHttpRequest {
1091            method: HttpMethod::Get,
1092            url: format!("http://{address}/probe"),
1093            headers: vec![RuntimeHttpHeader::new("accept", "application/json")],
1094            body: None,
1095        })?;
1096        let request = server
1097            .join()
1098            .map_err(|_| RuntimeHttpTestError::ServerThread)??;
1099
1100        let lower = request.to_ascii_lowercase();
1101        assert!(
1102            lower.contains("accept: application/json"),
1103            "caller Accept should be present: {request}"
1104        );
1105        assert!(
1106            !lower.contains("text/html"),
1107            "browser default Accept should be overridden, not duplicated: {request}"
1108        );
1109        Ok(())
1110    }
1111}