Skip to main content

acts_package_http/
package.rs

1use acts::{
2    ActError, ActPackage, ActPackageCatalog, ActPackageDefinition, ActRunAs, CancellationToken,
3    Context, Result, Vars, include_json,
4};
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use futures_util::StreamExt;
7use reqwest::dns::{Addrs, Name, Resolve, Resolving};
8use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, InvalidHeaderValue};
9use reqwest::redirect::Policy;
10use reqwest::{Client, Response, Url};
11use serde::{Deserialize, Serialize};
12use serde_json::Value as JsonValue;
13use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
14use std::sync::Arc;
15use std::time::Duration;
16use url::Host;
17
18const DATA_KEY: &str = "data";
19
20#[derive(Debug, Clone, Default, Deserialize, Serialize)]
21pub enum ContentType {
22    #[serde(rename(deserialize = "none"))]
23    None,
24    #[serde(rename(deserialize = "text"))]
25    Text,
26    #[serde(rename(deserialize = "html"))]
27    Html,
28    #[default]
29    #[serde(rename(deserialize = "json"))]
30    Json,
31    #[serde(rename(deserialize = "urlencoded"))]
32    UrlEncoded,
33    #[serde(rename(deserialize = "form-data"))]
34    FormData,
35    #[serde(rename(deserialize = "binary"))]
36    Binary,
37    #[serde(rename(deserialize = "image"))]
38    Image,
39    #[serde(rename(deserialize = "video"))]
40    Video,
41    #[serde(rename(deserialize = "audio"))]
42    Audio,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct Pair {
47    pub key: String,
48    pub value: JsonValue,
49}
50
51/// Bytes read from a response body before the request fails.
52pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
53
54/// Connect timeout applied when `[http].connect-timeout-ms` is not set.
55pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
56
57/// Largest `[http].connect-timeout-ms` accepted: the platform's ceiling on
58/// waiting for a connection.
59pub const MAX_CONNECT_TIMEOUT_MS: u64 = 5 * 60 * 1000;
60
61/// Whole-request timeout applied when neither `[http].timeout-ms` nor the
62/// act's own `timeout-ms` is set. A request without a deadline waits on the
63/// remote server for as long as it keeps the connection: a black-holed or
64/// endless response would hold the act — and its scheduler lane — with it.
65pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
66
67/// Largest `timeout-ms` accepted, from `[http]` or from an act: the platform's
68/// ceiling on how long one request may hold a lane. A longer wait belongs in a
69/// workflow-level timeout, not in a request that blocks its act.
70pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 1000;
71
72fn default_max_response_bytes() -> u64 {
73    DEFAULT_MAX_RESPONSE_BYTES
74}
75
76fn default_connect_timeout_ms() -> u64 {
77    DEFAULT_CONNECT_TIMEOUT_MS
78}
79
80fn default_timeout_ms() -> u64 {
81    DEFAULT_TIMEOUT_MS
82}
83
84/// Package-level `[http]` configuration.
85///
86/// ```toml
87/// [http]
88/// # Egress allowlist. When non-empty only these hosts can be requested;
89/// # "*.example.com" matches any subdomain but not example.com itself.
90/// allowed-hosts = ["api.example.com", "*.example.org"]
91/// # Opt-in for internal/on-prem endpoints. Cloud metadata addresses stay
92/// # blocked even when this is true.
93/// allow-private-addresses = false
94/// # Hard cap on a response body; larger bodies fail the act.
95/// max-response-bytes = 67108864
96/// # Connect timeout; defaults to 10000 (1..=300000)
97/// connect-timeout-ms = 10000
98/// # Whole-request timeout, including reading the body; defaults to 30000
99/// # (1..=3600000)
100/// timeout-ms = 30000
101/// ```
102///
103/// Both timeouts are always in force: there is no value that disables them,
104/// and a value outside its range is a startup error rather than a silent
105/// clamp. An act's own `timeout-ms` param overrides `timeout-ms` for that act
106/// (never above [`MAX_TIMEOUT_MS`]).
107#[derive(Debug, Clone, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109pub struct HttpConfig {
110    /// Hosts allowed to be requested. Empty means any host (subject to the
111    /// address checks below).
112    #[serde(default)]
113    pub allowed_hosts: Vec<String>,
114    /// Allow loopback/RFC1918/link-local/unique-local addresses. Cloud
115    /// metadata endpoints are rejected regardless.
116    #[serde(default)]
117    pub allow_private_addresses: bool,
118    /// Maximum response body size in bytes; the act fails beyond it.
119    #[serde(default = "default_max_response_bytes")]
120    pub max_response_bytes: u64,
121    /// Connection establishment timeout in milliseconds, in
122    /// `1..=MAX_CONNECT_TIMEOUT_MS`.
123    #[serde(default = "default_connect_timeout_ms")]
124    pub connect_timeout_ms: u64,
125    /// Total request timeout in milliseconds, including reading the body, in
126    /// `1..=MAX_TIMEOUT_MS`.
127    #[serde(default = "default_timeout_ms")]
128    pub timeout_ms: u64,
129}
130
131impl Default for HttpConfig {
132    fn default() -> Self {
133        Self {
134            allowed_hosts: Vec::new(),
135            allow_private_addresses: false,
136            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
137            connect_timeout_ms: DEFAULT_CONNECT_TIMEOUT_MS,
138            timeout_ms: DEFAULT_TIMEOUT_MS,
139        }
140    }
141}
142
143/// Egress rules enforced on every request: a host allowlist, an address
144/// range policy, and the connect-time resolver check.
145#[derive(Debug, Clone)]
146struct EgressPolicy {
147    allowed_hosts: Vec<String>,
148    allow_private_addresses: bool,
149}
150
151impl EgressPolicy {
152    fn new(config: &HttpConfig) -> Self {
153        Self {
154            allowed_hosts: config
155                .allowed_hosts
156                .iter()
157                .map(|host| host.trim().trim_end_matches('.').to_ascii_lowercase())
158                .filter(|host| !host.is_empty())
159                .collect(),
160            allow_private_addresses: config.allow_private_addresses,
161        }
162    }
163
164    /// Synchronous checks: scheme, host allowlist and literal-address policy.
165    fn check_url(&self, url: &Url) -> Result<()> {
166        let scheme = url.scheme();
167        if scheme != "http" && scheme != "https" {
168            return Err(ActError::Package(format!(
169                "http egress policy: unsupported url scheme '{scheme}'"
170            )));
171        }
172        let host = url_host(url)?;
173        if !self.host_allowed(&host) {
174            return Err(ActError::Package(format!(
175                "http egress policy: host '{host}' is not in allowed-hosts"
176            )));
177        }
178        if let Some(ip) = host_ip(url)
179            && !self.address_allowed(ip)
180        {
181            return Err(ActError::Package(format!(
182                "http egress policy: address {ip} is blocked"
183            )));
184        }
185        Ok(())
186    }
187
188    /// Resolve-time check: a hostname must resolve to at least one permitted
189    /// address. The connector resolver applies the same rule to the addresses
190    /// it actually dials, so a rebinding answer is still caught.
191    async fn check_resolved(&self, url: &Url) -> Result<()> {
192        let Some(Host::Domain(domain)) = url.host() else {
193            return Ok(());
194        };
195        let addrs = tokio::net::lookup_host((domain, 0)).await.map_err(|err| {
196            ActError::Runtime(format!(
197                "http egress policy: failed to resolve '{domain}': {err}"
198            ))
199        })?;
200        self.filter_addrs(domain, addrs).map(|_| ())
201    }
202
203    fn host_allowed(&self, host: &str) -> bool {
204        if self.allowed_hosts.is_empty() {
205            return true;
206        }
207        let host = host.trim_end_matches('.').to_ascii_lowercase();
208        self.allowed_hosts
209            .iter()
210            .any(|pattern| match pattern.strip_prefix("*.") {
211                Some(suffix) => host.ends_with(&format!(".{suffix}")),
212                None => host == *pattern,
213            })
214    }
215
216    fn address_allowed(&self, ip: IpAddr) -> bool {
217        if is_metadata(ip) || is_never_allowed(ip) {
218            return false;
219        }
220        self.allow_private_addresses || !is_private(ip)
221    }
222
223    /// Drop blocked addresses; error only when nothing permitted remains.
224    fn filter_addrs(
225        &self,
226        host: &str,
227        addrs: impl Iterator<Item = SocketAddr>,
228    ) -> Result<Vec<SocketAddr>> {
229        let mut permitted = Vec::new();
230        let mut blocked = Vec::new();
231        for addr in addrs {
232            if self.address_allowed(addr.ip()) {
233                permitted.push(addr);
234            } else {
235                blocked.push(addr.ip());
236            }
237        }
238        if permitted.is_empty() {
239            return Err(ActError::Package(format!(
240                "http egress policy: '{host}' resolves only to blocked addresses {blocked:?}"
241            )));
242        }
243        Ok(permitted)
244    }
245}
246
247fn url_host(url: &Url) -> Result<String> {
248    match url.host() {
249        Some(Host::Ipv4(ip)) => Ok(ip.to_string()),
250        Some(Host::Ipv6(ip)) => Ok(ip.to_string()),
251        Some(Host::Domain(domain)) => Ok(domain.to_string()),
252        None => Err(ActError::Package(
253            "http egress policy: url has no host".to_string(),
254        )),
255    }
256}
257
258fn host_ip(url: &Url) -> Option<IpAddr> {
259    match url.host()? {
260        Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
261        Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
262        Host::Domain(_) => None,
263    }
264}
265
266/// Address ranges rejected even when `allow-private-addresses` is set:
267/// unspecified, multicast, broadcast, reserved and documentation space.
268fn is_never_allowed(ip: IpAddr) -> bool {
269    match ip {
270        IpAddr::V4(ip) => {
271            ip.is_unspecified()
272                || ip.is_broadcast()
273                || ip.is_multicast()
274                || ip.is_documentation()
275                || ip.octets()[0] == 0
276                || ip.octets()[0] >= 240
277        }
278        IpAddr::V6(ip) => match ip.to_ipv4() {
279            Some(ip) => is_never_allowed(IpAddr::V4(ip)),
280            None => ip.is_unspecified() || ip.is_multicast(),
281        },
282    }
283}
284
285/// Loopback, RFC1918, link-local, carrier-grade NAT and IPv6 unique-local
286/// ranges. IPv4-mapped IPv6 addresses are judged by their IPv4 value.
287fn is_private(ip: IpAddr) -> bool {
288    match ip {
289        IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local() || is_cgnat(ip),
290        IpAddr::V6(ip) => match ip.to_ipv4() {
291            Some(ip) => is_private(IpAddr::V4(ip)),
292            None => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(),
293        },
294    }
295}
296
297/// 100.64.0.0/10 carrier-grade NAT, which includes the Alibaba Cloud
298/// metadata endpoint 100.100.100.200.
299fn is_cgnat(ip: Ipv4Addr) -> bool {
300    let [a, b, ..] = ip.octets();
301    a == 100 && (64..128).contains(&b)
302}
303
304/// Cloud instance metadata endpoints: AWS/GCP/Azure/DigitalOcean share
305/// 169.254.169.254, Alibaba Cloud uses 100.100.100.200 and the AWS IPv6
306/// endpoint is fd00:ec2::254.
307fn is_metadata(ip: IpAddr) -> bool {
308    match ip {
309        IpAddr::V4(ip) => {
310            let octets = ip.octets();
311            octets == [169, 254, 169, 254] || octets == [100, 100, 100, 200]
312        }
313        IpAddr::V6(ip) => match ip.to_ipv4() {
314            Some(ip) => is_metadata(IpAddr::V4(ip)),
315            None => ip == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254),
316        },
317    }
318}
319
320/// Resolver that filters blocked addresses before the connector dials them.
321#[derive(Debug)]
322struct SafeResolver {
323    policy: Arc<EgressPolicy>,
324}
325
326impl Resolve for SafeResolver {
327    fn resolve(&self, name: Name) -> Resolving {
328        let host = name.as_str().to_string();
329        let policy = self.policy.clone();
330        Box::pin(async move {
331            let addrs = tokio::net::lookup_host((host.as_str(), 0))
332                .await
333                .map_err(|err| boxed_err(format!("failed to resolve '{host}': {err}")))?;
334            let permitted = policy
335                .filter_addrs(&host, addrs)
336                .map_err(|err| boxed_err(err.to_string()))?;
337            Ok(Box::new(permitted.into_iter()) as Addrs)
338        })
339    }
340}
341
342fn boxed_err(message: String) -> Box<dyn std::error::Error + Send + Sync> {
343    message.into()
344}
345
346#[derive(Debug, Clone)]
347pub struct HttpPackage {
348    client: Client,
349    policy: Arc<EgressPolicy>,
350    max_response_bytes: u64,
351    /// The configured whole-request timeout: the ceiling an act's own
352    /// `timeout-ms` may tighten, never widen.
353    timeout_ms: u64,
354}
355
356#[derive(Debug, Clone, Deserialize, Serialize)]
357pub struct HttpPackageParams {
358    pub url: String,
359    pub method: String,
360    #[serde(default)]
361    #[serde(rename(deserialize = "content-type"))]
362    pub content_type: ContentType,
363    #[serde(default)]
364    pub headers: Vec<Pair>,
365    #[serde(default)]
366    pub params: Vec<Pair>,
367    pub body: Option<JsonValue>,
368    /// Total request timeout in milliseconds for this act. Only ever a
369    /// tightening of the client-wide `[http].timeout-ms` — a value above it
370    /// (or zero, which is what leaves a request unbounded) fails the act.
371    /// Unset means the configured bound applies.
372    #[serde(default, rename(deserialize = "timeout-ms"))]
373    pub timeout_ms: Option<u64>,
374}
375
376#[async_trait::async_trait]
377impl ActPackage for HttpPackage {
378    fn definition() -> ActPackageDefinition {
379        ActPackageDefinition {
380            id: "acts.core.http",
381            name: "Http",
382            desc: "do a http request",
383            version: "0.1.0",
384            icon: r#"<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"><path d="M53.312 512a458.688 458.688 0 1 1 917.376 0A458.688 458.688 0 0 1 53.312 512z m339.52-376.32a394.048 394.048 0 0 0-138.88 77.632c25.6 22.08 53.952 40.96 84.48 55.936 6.784-25.408 14.528-49.152 23.168-70.848 9.216-22.912 19.584-44.16 31.232-62.72zM209.024 258.944A392.896 392.896 0 0 0 118.592 480h191.232c1.472-51.584 6.528-100.992 14.656-146.688a459.136 459.136 0 0 1-115.456-74.24z m422.144 629.376a394.176 394.176 0 0 0 138.88-77.696 395.328 395.328 0 0 0-84.48-55.936 610.752 610.752 0 0 1-23.168 70.848c-9.152 22.912-19.584 44.096-31.232 62.72z m183.808-123.456A392.832 392.832 0 0 0 905.408 544H714.24a1008.704 1008.704 0 0 1-14.72 146.624c42.24 19.008 81.152 44.16 115.456 74.304zM905.408 480a392.96 392.96 0 0 0-90.432-220.928 459.2 459.2 0 0 1-115.392 74.24c8.064 45.696 13.12 95.104 14.656 146.688h191.168zM769.92 213.312a394.112 394.112 0 0 0-138.88-77.696c11.712 18.688 22.144 39.872 31.296 62.784 8.704 21.76 16.448 45.44 23.104 70.848a395.328 395.328 0 0 0 84.48-55.936zM392.832 888.384a399.04 399.04 0 0 1-31.232-62.784 610.624 610.624 0 0 1-23.104-70.848 395.264 395.264 0 0 0-84.48 55.936 394.048 394.048 0 0 0 138.88 77.696z m-183.744-123.456a459.136 459.136 0 0 1 115.392-74.24A1008.448 1008.448 0 0 1 309.76 544H118.656a392.896 392.896 0 0 0 90.496 220.928zM512 117.312c-11.904 0-26.496 5.952-43.2 23.552-16.64 17.664-33.216 44.928-47.744 81.28-8.448 21.12-16 44.8-22.528 70.656A394.752 394.752 0 0 0 512 309.312c39.488 0 77.568-5.76 113.536-16.512a557.824 557.824 0 0 0-22.528-70.592c-14.592-36.416-31.104-63.68-47.808-81.344-16.64-17.6-31.232-23.552-43.2-23.552zM373.824 480h276.352a953.28 953.28 0 0 0-11.712-124.352A458.88 458.88 0 0 1 512 373.312a458.88 458.88 0 0 1-126.4-17.664A953.216 953.216 0 0 0 373.76 480z m11.776 188.352A458.944 458.944 0 0 1 512 650.688c43.84 0 86.272 6.144 126.464 17.664 6.272-38.656 10.368-80.448 11.712-124.352H373.824c1.344 43.904 5.44 85.76 11.776 124.352zM512 714.688c-39.424 0-77.568 5.76-113.472 16.512 6.464 25.792 14.08 49.472 22.528 70.592 14.528 36.416 31.04 63.68 47.744 81.344 16.64 17.6 31.296 23.552 43.2 23.552 11.968 0 26.56-5.952 43.2-23.552 16.704-17.664 33.28-44.928 47.808-81.28 8.448-21.184 16-44.8 22.464-70.656A394.752 394.752 0 0 0 512 714.688z" ></path></svg>"#,
385            doc: "",
386            schema: include_json!("./in-schema.json"),
387            options: Some(include_json!("./ui-schema.json")),
388            run_as: ActRunAs::Func,
389            resources: vec![],
390            catalog: ActPackageCatalog::Core,
391        }
392    }
393
394    fn new(config: &acts::Config) -> Result<Self>
395    where
396        Self: Sized,
397    {
398        let config = if config.has("http") {
399            config.get::<HttpConfig>("http")?
400        } else {
401            HttpConfig::default()
402        };
403        Self::from_config(&config)
404    }
405
406    async fn execute(&self, ctx: &Context, params: &serde_json::Value) -> Result<Option<Vars>> {
407        let params = serde_json::from_value::<HttpPackageParams>(params.clone()).map_err(|e| {
408            ActError::Package(format!(
409                "invalid ActPackage({}) params: {}",
410                Self::definition().id,
411                e
412            ))
413        })?;
414
415        self.request(&ctx.cancellation_token(), &params).await
416    }
417}
418
419impl HttpPackage {
420    /// Build the package from an explicit `[http]` config, bypassing the
421    /// engine config lookup.
422    pub fn from_config(config: &HttpConfig) -> Result<Self> {
423        if config.max_response_bytes == 0 {
424            return Err(ActError::Config(
425                "http.max-response-bytes must be greater than zero".to_string(),
426            ));
427        }
428        // Both timeouts are always in force: zero (the unbounded value) and
429        // anything above the platform's ceiling are refused at load, never
430        // silently replaced by a bound the deployment did not ask for.
431        if !(1..=MAX_CONNECT_TIMEOUT_MS).contains(&config.connect_timeout_ms) {
432            return Err(ActError::Config(format!(
433                "http.connect-timeout-ms must be between 1 and {MAX_CONNECT_TIMEOUT_MS} \
434                 (got {})",
435                config.connect_timeout_ms
436            )));
437        }
438        if !(1..=MAX_TIMEOUT_MS).contains(&config.timeout_ms) {
439            return Err(ActError::Config(format!(
440                "http.timeout-ms must be between 1 and {MAX_TIMEOUT_MS} (got {})",
441                config.timeout_ms
442            )));
443        }
444
445        let policy = Arc::new(EgressPolicy::new(config));
446        let client = Client::builder()
447            .dns_resolver(Arc::new(SafeResolver {
448                policy: policy.clone(),
449            }))
450            .redirect(redirect_policy(policy.clone()))
451            .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
452            .timeout(Duration::from_millis(config.timeout_ms))
453            .build()
454            .map_err(|err| ActError::Config(format!("failed to build http client: {err}")))?;
455
456        Ok(Self {
457            client,
458            policy,
459            max_response_bytes: config.max_response_bytes,
460            timeout_ms: config.timeout_ms,
461        })
462    }
463
464    async fn request(
465        &self,
466        cancel: &CancellationToken,
467        params: &HttpPackageParams,
468    ) -> Result<Option<Vars>> {
469        let mut ret = Vars::new();
470        let mut headers = HeaderMap::new();
471        headers.insert(
472            HeaderName::from_static("accept"),
473            HeaderValue::from_static("*/*"),
474        );
475
476        for Pair { key, value } in &params.headers {
477            headers.insert(
478                key.parse::<HeaderName>()
479                    .map_err(|err| ActError::Runtime(err.to_string()))?,
480                value
481                    .to_string()
482                    .parse()
483                    .map_err(|err: InvalidHeaderValue| ActError::Runtime(err.to_string()))?,
484            );
485        }
486        let mut query = Vec::new();
487        for Pair { key, value } in &params.params {
488            query.push((key.clone(), value.clone()));
489        }
490
491        let url = Url::parse(&params.url)
492            .map_err(|err| ActError::Package(format!("invalid url '{}': {err}", params.url)))?;
493        self.policy.check_url(&url)?;
494        self.policy.check_resolved(&url).await?;
495
496        let method: reqwest::Method = params
497            .method
498            .parse()
499            .map_err(|_| ActError::Runtime(format!("invalid method '{}'", params.method)))?;
500        let mut request = self
501            .client
502            .request(method, url)
503            .headers(headers)
504            .query(&query);
505        if let Some(timeout_ms) = params.timeout_ms {
506            // An act may only tighten the deployment's bound: how long one
507            // request may hold a lane is the deployment's judgement, and the
508            // act's own `timeout-ms` cannot widen it — nor disable it, since
509            // the client-wide bound already applies.
510            if timeout_ms == 0 || timeout_ms > self.timeout_ms {
511                return Err(ActError::Package(format!(
512                    "timeout-ms must be between 1 and the configured \
513                     [http].timeout-ms ({}) (got {timeout_ms})",
514                    self.timeout_ms
515                )));
516            }
517            request = request.timeout(Duration::from_millis(timeout_ms));
518        }
519
520        match params.content_type {
521            ContentType::Text | ContentType::Html => {
522                if let Some(text) = &params.body {
523                    let data = text.as_str().ok_or(ActError::Package(
524                        "content-type did not match the body content".to_string(),
525                    ))?;
526                    request = request.body::<String>(data.to_string());
527                }
528            }
529            ContentType::Json => {
530                if let Some(json) = &params.body {
531                    let body = serde_json::to_vec(json)?;
532                    request = request.body(body);
533                }
534            }
535            ContentType::FormData | ContentType::UrlEncoded => {
536                if let Some(form) = &params.body {
537                    let data = form.as_object().ok_or(ActError::Package(
538                        "content-type did not match the body content".to_string(),
539                    ))?;
540                    request = request.form(data);
541                }
542            }
543            ContentType::Binary | ContentType::Image | ContentType::Video | ContentType::Audio => {
544                if let Some(value) = &params.body {
545                    let data = value.as_str().ok_or(ActError::Package(
546                        "content-type did not match the body content".to_string(),
547                    ))?;
548                    let data = STANDARD
549                        .decode(data)
550                        .map_err(|err| ActError::Package(err.to_string()))?;
551                    request = request.body(data);
552                }
553            }
554            _ => {}
555        }
556
557        // The request is raced against the act's cancellation: a cancelled
558        // act must not keep its scheduler lane waiting on a remote server.
559        // Cancellation reports no outcome of its own — it is not this act's
560        // failure: the action that overrode the task owns its state, and a
561        // shutdown leaves the task for the next start to resume.
562        let res = tokio::select! {
563            res = request.send() => res.map_err(map_send_err)?,
564            _ = cancel.cancelled() => return Ok(None),
565        };
566
567        let status = res.status();
568        let content_type = match res.headers().get(CONTENT_TYPE) {
569            Some(value) => Some(
570                value
571                    .to_str()
572                    .map_err(|err| ActError::Package(err.to_string()))?
573                    .to_string(),
574            ),
575            None => None,
576        };
577        let response_type = get_content_type(content_type.as_deref().unwrap_or("application/json"));
578        if !matches!(response_type, ContentType::None) {
579            let Some(body) = read_body_capped(res, self.max_response_bytes, cancel).await? else {
580                return Ok(None);
581            };
582            match response_type {
583                ContentType::Text | ContentType::Html => {
584                    ret.insert(
585                        DATA_KEY.to_string(),
586                        decode_text(&body, content_type.as_deref()).into(),
587                    );
588                }
589                ContentType::Json => {
590                    let data = serde_json::from_slice::<JsonValue>(&body).map_err(|err| {
591                        ActError::Package(format!("failed to parse json response: {err}"))
592                    })?;
593                    ret.insert(DATA_KEY.to_string(), data);
594                }
595                ContentType::Binary
596                | ContentType::Image
597                | ContentType::Video
598                | ContentType::Audio => {
599                    ret.insert(DATA_KEY.to_string(), STANDARD.encode(&body).into());
600                }
601                _ => {}
602            }
603        }
604        if !status.is_success() {
605            return Err(ActError::Exception {
606                ecode: status.as_u16().to_string(),
607                message: ret.get(DATA_KEY).unwrap_or(status.to_string()),
608            });
609        }
610
611        Ok(Some(ret))
612    }
613}
614
615fn map_package_err(err: reqwest::Error) -> ActError {
616    ActError::Package(error_chain(&err))
617}
618
619/// The error and its source chain on one line: reqwest's `Display` prints only
620/// a generic prefix, and the reason — a timeout, a policy refusal, a truncated
621/// body — lives further down the chain.
622fn error_chain(err: &dyn std::error::Error) -> String {
623    let mut message = err.to_string();
624    let mut source = err.source();
625    while let Some(err) = source {
626        message.push_str(": ");
627        message.push_str(&err.to_string());
628        source = err.source();
629    }
630    message
631}
632
633fn get_content_type(mime_type: &str) -> ContentType {
634    let mut ret = ContentType::None;
635    if mime_type.starts_with("application/json") {
636        ret = ContentType::Json;
637    } else if mime_type.starts_with("text/html") {
638        ret = ContentType::Html;
639    } else if mime_type.starts_with("application/x-www-form-urlencoded") {
640        ret = ContentType::UrlEncoded;
641    } else if mime_type.starts_with("multipart/form-data") {
642        ret = ContentType::FormData;
643    } else if mime_type.starts_with("image/") {
644        ret = ContentType::Image;
645    } else if mime_type.starts_with("audio/") {
646        ret = ContentType::Audio;
647    } else if mime_type.starts_with("video/") {
648        ret = ContentType::Video;
649    } else if mime_type.starts_with("text/") || mime_type.starts_with("application/javascript") {
650        ret = ContentType::Text;
651    }
652
653    ret
654}
655
656/// Read a body into memory, failing once `max_bytes` is exceeded. Streaming
657/// keeps the cap enforced for chunked responses without `Content-Length`.
658///
659/// The chunks are read under the act's cancellation as well as the request's
660/// own timeout: a server that keeps a body open cannot hold a cancelled act's
661/// lane while it waits for the next chunk.
662async fn read_body_capped(
663    res: Response,
664    max_bytes: u64,
665    cancel: &CancellationToken,
666) -> Result<Option<Vec<u8>>> {
667    if let Some(length) = res.content_length()
668        && length > max_bytes
669    {
670        return Err(over_limit(max_bytes));
671    }
672    let mut body = Vec::new();
673    let mut stream = res.bytes_stream();
674    loop {
675        let chunk = tokio::select! {
676            chunk = stream.next() => chunk,
677            _ = cancel.cancelled() => return Ok(None),
678        };
679        let Some(chunk) = chunk else {
680            break;
681        };
682        let chunk = chunk.map_err(map_package_err)?;
683        if body.len() as u64 + chunk.len() as u64 > max_bytes {
684            return Err(over_limit(max_bytes));
685        }
686        body.extend_from_slice(&chunk);
687    }
688    Ok(Some(body))
689}
690
691fn over_limit(max_bytes: u64) -> ActError {
692    ActError::Package(format!(
693        "http response body exceeds max-response-bytes ({max_bytes})"
694    ))
695}
696
697/// Decode a text/html body honoring the response charset, mirroring
698/// `reqwest::Response::text` which the capped reader replaced.
699fn decode_text(body: &[u8], content_type: Option<&str>) -> String {
700    let charset = content_type
701        .and_then(|value| {
702            value.split(';').skip(1).find_map(|part| {
703                let (name, value) = part.split_once('=')?;
704                name.trim()
705                    .eq_ignore_ascii_case("charset")
706                    .then(|| value.trim().trim_matches('"'))
707            })
708        })
709        .unwrap_or("utf-8");
710    let encoding =
711        encoding_rs::Encoding::for_label(charset.as_bytes()).unwrap_or(encoding_rs::UTF_8);
712    let (text, _, _) = encoding.decode(body);
713    text.into_owned()
714}
715
716/// Redirects are re-validated against the egress policy so a permitted URL
717/// cannot bounce the client to a loopback/private/metadata target.
718fn redirect_policy(policy: Arc<EgressPolicy>) -> Policy {
719    Policy::custom(move |attempt| {
720        if attempt.previous().len() >= 10 {
721            return attempt.error("too many redirects");
722        }
723        match policy.check_url(attempt.url()) {
724            Ok(()) => attempt.follow(),
725            Err(err) => attempt.error(err.to_string()),
726        }
727    })
728}
729
730/// A send failure is the engine's to retry or surface as an error; the chain
731/// is included like [`map_package_err`]'s, so the reason is visible.
732fn map_send_err(err: reqwest::Error) -> ActError {
733    ActError::Runtime(format!("Http error: {}", error_chain(&err)))
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use tokio::io::{AsyncReadExt, AsyncWriteExt};
740    use tokio::net::TcpListener;
741
742    fn params(url: String) -> HttpPackageParams {
743        HttpPackageParams {
744            url,
745            method: "GET".to_string(),
746            content_type: ContentType::Json,
747            headers: Vec::new(),
748            params: Vec::new(),
749            body: None,
750            timeout_ms: None,
751        }
752    }
753
754    fn package(config: HttpConfig) -> HttpPackage {
755        HttpPackage::from_config(&config).expect("build http package")
756    }
757
758    /// A token that never fires: these tests drive the request path directly,
759    /// without an act to cancel.
760    fn never() -> CancellationToken {
761        CancellationToken::new()
762    }
763
764    /// Serve one HTTP/1.1 response on an ephemeral loopback port.
765    async fn serve(response: Vec<u8>) -> SocketAddr {
766        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
767        let addr = listener.local_addr().unwrap();
768        tokio::spawn(async move {
769            let (mut socket, _) = listener.accept().await.unwrap();
770            let mut buf = [0u8; 1024];
771            let _ = socket.read(&mut buf).await;
772            let _ = socket.write_all(&response).await;
773            let _ = socket.shutdown().await;
774        });
775        addr
776    }
777
778    #[test]
779    fn blocks_loopback_private_link_local_and_metadata() {
780        let policy = EgressPolicy::new(&HttpConfig::default());
781        for ip in [
782            "127.0.0.1",
783            "10.0.0.1",
784            "172.16.5.4",
785            "192.168.1.1",
786            "169.254.169.254",
787            "100.100.100.200",
788            "0.0.0.0",
789            "224.0.0.1",
790            "::1",
791            "fe80::1",
792            "fd00::1",
793            "::ffff:127.0.0.1",
794        ] {
795            let ip: IpAddr = ip.parse().unwrap();
796            assert!(!policy.address_allowed(ip), "{ip} must be blocked");
797        }
798        for ip in ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"] {
799            let ip: IpAddr = ip.parse().unwrap();
800            assert!(policy.address_allowed(ip), "{ip} must be allowed");
801        }
802    }
803
804    #[test]
805    fn private_opt_in_keeps_metadata_and_reserved_blocked() {
806        let policy = EgressPolicy::new(&HttpConfig {
807            allow_private_addresses: true,
808            ..Default::default()
809        });
810        assert!(policy.address_allowed("192.168.1.1".parse().unwrap()));
811        assert!(policy.address_allowed("127.0.0.1".parse().unwrap()));
812        assert!(!policy.address_allowed("169.254.169.254".parse().unwrap()));
813        assert!(!policy.address_allowed("100.100.100.200".parse().unwrap()));
814        assert!(!policy.address_allowed("0.0.0.0".parse().unwrap()));
815    }
816
817    #[test]
818    fn allowed_hosts_match_exact_and_wildcard() {
819        let policy = EgressPolicy::new(&HttpConfig {
820            allowed_hosts: vec!["api.example.com".into(), "*.example.org".into()],
821            ..Default::default()
822        });
823        assert!(policy.host_allowed("api.example.com"));
824        assert!(policy.host_allowed("API.EXAMPLE.COM"));
825        assert!(policy.host_allowed("a.example.org"));
826        assert!(!policy.host_allowed("example.org"));
827        assert!(!policy.host_allowed("api.example.com.evil.com"));
828        assert!(!policy.host_allowed("evil.com"));
829    }
830
831    #[tokio::test]
832    async fn rejects_loopback_ip_literal() {
833        let err = package(HttpConfig::default())
834            .request(&never(), &params("http://127.0.0.1:9/".into()))
835            .await
836            .unwrap_err();
837        assert!(
838            err.to_string().contains("address 127.0.0.1 is blocked"),
839            "{err}"
840        );
841    }
842
843    #[tokio::test]
844    async fn rejects_hostname_resolving_to_loopback() {
845        let err = package(HttpConfig::default())
846            .request(&never(), &params("http://localhost:9/".into()))
847            .await
848            .unwrap_err();
849        let message = err.to_string();
850        assert!(
851            message.contains("blocked addresses") || message.contains("failed to resolve"),
852            "{message}"
853        );
854    }
855
856    #[tokio::test]
857    async fn rejects_host_outside_allowlist() {
858        let config = HttpConfig {
859            allowed_hosts: vec!["allowed.example".into()],
860            ..Default::default()
861        };
862        let err = package(config)
863            .request(&never(), &params("http://blocked.example/".into()))
864            .await
865            .unwrap_err();
866        assert!(err.to_string().contains("not in allowed-hosts"), "{err}");
867    }
868
869    #[tokio::test]
870    async fn reads_body_within_limit() {
871        let addr = serve(
872            b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"
873                .to_vec(),
874        )
875        .await;
876        let config = HttpConfig {
877            allow_private_addresses: true,
878            ..Default::default()
879        };
880        let out = package(config)
881            .request(&never(), &params(format!("http://{addr}/")))
882            .await
883            .unwrap()
884            .unwrap();
885        let data: String = out.get(DATA_KEY).unwrap();
886        assert_eq!(data, "hello");
887    }
888
889    #[tokio::test]
890    async fn rejects_body_over_content_length() {
891        let addr = serve(
892            b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 1000\r\n\r\n".to_vec(),
893        )
894        .await;
895        let config = HttpConfig {
896            allow_private_addresses: true,
897            max_response_bytes: 16,
898            ..Default::default()
899        };
900        let err = package(config)
901            .request(&never(), &params(format!("http://{addr}/")))
902            .await
903            .unwrap_err();
904        assert!(err.to_string().contains("max-response-bytes"), "{err}");
905    }
906
907    #[tokio::test]
908    async fn rejects_chunked_body_over_limit() {
909        let chunk = "x".repeat(64);
910        let response = format!(
911            "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n{chunk}\r\n0\r\n\r\n",
912            chunk.len()
913        );
914        let addr = serve(response.into_bytes()).await;
915        let config = HttpConfig {
916            allow_private_addresses: true,
917            max_response_bytes: 16,
918            ..Default::default()
919        };
920        let err = package(config)
921            .request(&never(), &params(format!("http://{addr}/")))
922            .await
923            .unwrap_err();
924        assert!(err.to_string().contains("max-response-bytes"), "{err}");
925    }
926
927    #[tokio::test]
928    async fn request_timeout_is_enforced() {
929        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
930        let addr = listener.local_addr().unwrap();
931        tokio::spawn(async move {
932            let (mut socket, _) = listener.accept().await.unwrap();
933            let mut buf = [0u8; 1024];
934            let _ = socket.read(&mut buf).await;
935            tokio::time::sleep(Duration::from_secs(5)).await;
936            let _ = socket
937                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
938                .await;
939        });
940        // the act tightens the client-wide bound (30s) to 150ms
941        let mut params = params(format!("http://{addr}/"));
942        params.timeout_ms = Some(150);
943        let config = HttpConfig {
944            allow_private_addresses: true,
945            ..Default::default()
946        };
947        let err = package(config)
948            .request(&never(), &params)
949            .await
950            .unwrap_err();
951        assert!(err.to_string().contains("Http error"), "{err}");
952    }
953
954    #[tokio::test]
955    async fn blocks_redirect_outside_allowlist() {
956        let response =
957            b"HTTP/1.1 302 Found\r\nLocation: http://10.0.0.1:9/secret\r\nContent-Length: 0\r\n\r\n"
958                .to_vec();
959        let addr = serve(response).await;
960        let config = HttpConfig {
961            allowed_hosts: vec!["127.0.0.1".into()],
962            allow_private_addresses: true,
963            ..Default::default()
964        };
965        let err = package(config)
966            .request(&never(), &params(format!("http://{addr}/")))
967            .await
968            .unwrap_err();
969        assert!(err.to_string().contains("not in allowed-hosts"), "{err}");
970    }
971
972    /// The request deadline covers the whole request, not just the connection:
973    /// a server that answers the headers and then stops sending leaves the act
974    /// waiting for the body, and that wait is the timeout's to end.
975    #[tokio::test]
976    async fn request_timeout_covers_a_stalled_body() {
977        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
978        let addr = listener.local_addr().unwrap();
979        tokio::spawn(async move {
980            let (mut socket, _) = listener.accept().await.unwrap();
981            let mut buf = [0u8; 1024];
982            let _ = socket.read(&mut buf).await;
983            // headers promise 100 bytes, then the server goes quiet with the
984            // connection open
985            let _ = socket
986                .write_all(
987                    b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 100\r\n\r\npartial",
988                )
989                .await;
990            tokio::time::sleep(Duration::from_secs(5)).await;
991        });
992        let config = HttpConfig {
993            allow_private_addresses: true,
994            timeout_ms: 300,
995            ..Default::default()
996        };
997
998        let start = std::time::Instant::now();
999        let err = package(config)
1000            .request(&never(), &params(format!("http://{addr}/")))
1001            .await
1002            .unwrap_err();
1003        let elapsed = start.elapsed();
1004
1005        assert!(
1006            err.to_string().contains("timed out"),
1007            "the stall must end as a timeout, not as a truncated body: {err}"
1008        );
1009        assert!(
1010            elapsed < Duration::from_secs(3),
1011            "the act waited for the server instead of its deadline: {elapsed:?}"
1012        );
1013    }
1014
1015    /// A request that is never answered fails at the client-wide deadline when
1016    /// the act sets none of its own — the default is a bound, not "no bound".
1017    #[tokio::test]
1018    async fn the_config_default_bounds_an_act_without_a_param() {
1019        assert_eq!(HttpConfig::default().timeout_ms, DEFAULT_TIMEOUT_MS);
1020
1021        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1022        let addr = listener.local_addr().unwrap();
1023        tokio::spawn(async move {
1024            // accept and never answer
1025            let (_socket, _) = listener.accept().await.unwrap();
1026            tokio::time::sleep(Duration::from_secs(5)).await;
1027        });
1028        let config = HttpConfig {
1029            allow_private_addresses: true,
1030            timeout_ms: 300,
1031            ..Default::default()
1032        };
1033        let err = package(config)
1034            .request(&never(), &params(format!("http://{addr}/")))
1035            .await
1036            .unwrap_err();
1037        assert!(err.to_string().contains("timed out"), "{err}");
1038    }
1039
1040    /// Both timeouts are always in force: zero (the unbounded value) and
1041    /// anything above the platform ceiling fail at load, rather than running a
1042    /// request with a bound the deployment did not ask for.
1043    #[test]
1044    fn a_timeout_outside_the_platform_range_is_a_config_error() {
1045        for config in [
1046            HttpConfig {
1047                timeout_ms: 0,
1048                ..Default::default()
1049            },
1050            HttpConfig {
1051                timeout_ms: MAX_TIMEOUT_MS + 1,
1052                ..Default::default()
1053            },
1054            HttpConfig {
1055                connect_timeout_ms: 0,
1056                ..Default::default()
1057            },
1058            HttpConfig {
1059                connect_timeout_ms: MAX_CONNECT_TIMEOUT_MS + 1,
1060                ..Default::default()
1061            },
1062        ] {
1063            let err = HttpPackage::from_config(&config).unwrap_err();
1064            assert!(
1065                matches!(err, ActError::Config(_)),
1066                "expected a config error, got {err:?}"
1067            );
1068        }
1069
1070        // the inclusive ends are accepted
1071        HttpPackage::from_config(&HttpConfig {
1072            timeout_ms: MAX_TIMEOUT_MS,
1073            connect_timeout_ms: MAX_CONNECT_TIMEOUT_MS,
1074            ..Default::default()
1075        })
1076        .expect("the ceilings are valid values");
1077    }
1078
1079    /// An act may only tighten the configured bound: a `timeout-ms` above it,
1080    /// or zero (the unbounded value), fails the act before anything is sent.
1081    #[tokio::test]
1082    async fn an_act_timeout_may_only_tighten_the_configured_bound() {
1083        let config = HttpConfig {
1084            allow_private_addresses: true,
1085            timeout_ms: 1_000,
1086            ..Default::default()
1087        };
1088        for timeout_ms in [0, 1_001, MAX_TIMEOUT_MS] {
1089            let mut params = params("http://127.0.0.1:9/".into());
1090            params.timeout_ms = Some(timeout_ms);
1091            let err = package(config.clone())
1092                .request(&never(), &params)
1093                .await
1094                .unwrap_err();
1095            assert!(
1096                matches!(err, ActError::Package(_)),
1097                "expected a package error, got {err:?}"
1098            );
1099            assert!(
1100                err.to_string().contains("[http].timeout-ms (1000)"),
1101                "the failure must name the configured bound: {err}"
1102            );
1103        }
1104    }
1105
1106    /// A cancelled act gives up its request before any deadline, and reports
1107    /// no outcome of its own: it did not fail, it was stopped.
1108    #[tokio::test]
1109    async fn a_cancelled_act_gives_up_its_request() {
1110        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1111        let addr = listener.local_addr().unwrap();
1112        tokio::spawn(async move {
1113            let (_socket, _) = listener.accept().await.unwrap();
1114            tokio::time::sleep(Duration::from_secs(5)).await;
1115        });
1116        let config = HttpConfig {
1117            allow_private_addresses: true,
1118            timeout_ms: 60_000,
1119            ..Default::default()
1120        };
1121        let cancel = CancellationToken::new();
1122        let token = cancel.clone();
1123        tokio::spawn(async move {
1124            tokio::time::sleep(Duration::from_millis(100)).await;
1125            token.cancel();
1126        });
1127
1128        let start = std::time::Instant::now();
1129        let out = package(config)
1130            .request(&cancel, &params(format!("http://{addr}/")))
1131            .await
1132            .expect("a cancelled act reports no failure of its own");
1133
1134        assert!(out.is_none(), "a cancelled act yields no outcome: {out:?}");
1135        assert!(
1136            start.elapsed() < Duration::from_secs(3),
1137            "the act waited for its deadline instead of the cancellation"
1138        );
1139    }
1140}