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
51pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
53
54pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
56
57pub const MAX_CONNECT_TIMEOUT_MS: u64 = 5 * 60 * 1000;
60
61pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
66
67pub 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#[derive(Debug, Clone, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109pub struct HttpConfig {
110 #[serde(default)]
113 pub allowed_hosts: Vec<String>,
114 #[serde(default)]
117 pub allow_private_addresses: bool,
118 #[serde(default = "default_max_response_bytes")]
120 pub max_response_bytes: u64,
121 #[serde(default = "default_connect_timeout_ms")]
124 pub connect_timeout_ms: u64,
125 #[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#[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 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 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 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
266fn 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
285fn 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
297fn is_cgnat(ip: Ipv4Addr) -> bool {
300 let [a, b, ..] = ip.octets();
301 a == 100 && (64..128).contains(&b)
302}
303
304fn 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#[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 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 #[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(), ¶ms).await
416 }
417}
418
419impl HttpPackage {
420 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 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 ¶ms.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 ¶ms.params {
488 query.push((key.clone(), value.clone()));
489 }
490
491 let url = Url::parse(¶ms.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 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) = ¶ms.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) = ¶ms.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) = ¶ms.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) = ¶ms.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 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
619fn 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
656async 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
697fn 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
716fn 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
730fn 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 fn never() -> CancellationToken {
761 CancellationToken::new()
762 }
763
764 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(), ¶ms("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(), ¶ms("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(), ¶ms("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(), ¶ms(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(), ¶ms(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(), ¶ms(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 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(), ¶ms)
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(), ¶ms(format!("http://{addr}/")))
967 .await
968 .unwrap_err();
969 assert!(err.to_string().contains("not in allowed-hosts"), "{err}");
970 }
971
972 #[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 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(), ¶ms(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 #[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 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(), ¶ms(format!("http://{addr}/")))
1035 .await
1036 .unwrap_err();
1037 assert!(err.to_string().contains("timed out"), "{err}");
1038 }
1039
1040 #[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 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 #[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(), ¶ms)
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 #[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, ¶ms(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}