forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
23.1 KiB
1//! SSRF-safe outbound HTTP for webhook deliveries.
2//!
3//! Every destination is checked twice: once when a hook URL is saved ([`validate_url`]) and again
4//! at send time. Hostnames are resolved by [`PolicyResolver`], which drops internal addresses before
5//! reqwest can connect, so a DNS rebind cannot slip past the check. Literal IP hosts are checked
6//! directly, since the resolver is not consulted for them. Redirects are never followed.
7
8use std::fmt;
9use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
10use std::str::FromStr;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use forge_core::config::WebhookConfig;
15use futures_util::StreamExt;
16use hmac::{Hmac, KeyInit, Mac};
17use ipnet::IpNet;
18use reqwest::Url;
19use reqwest::dns::{Addrs, Name, Resolve, Resolving};
20use reqwest::header::{HeaderName, HeaderValue};
21use sha2::Sha256;
22
23/// Longest accepted hook URL, in bytes.
24pub const MAX_URL_LEN: usize = 2048;
25/// Most response body bytes kept in a [`SendOutcome`].
26pub const MAX_BODY_BYTES: usize = 64 * 1024;
27
28type HmacSha256 = Hmac<Sha256>;
29type BoxError = Box<dyn std::error::Error + Send + Sync>;
30
31/// One entry of the allow list: an exact hostname (case-insensitive) or a CIDR network.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum AllowEntry {
34 Host(String),
35 Net(IpNet),
36}
37
38/// Outbound policy derived from the `[webhooks]` config section.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct WebhookPolicy {
41 pub allow_http: bool,
42 pub allowed_hosts: Vec<AllowEntry>,
43}
44
45impl WebhookPolicy {
46 /// Builds the policy from config. An entry containing `/` must be a CIDR network, a bare IP
47 /// address becomes a single-host network, and anything else is a hostname.
48 pub fn from_config(config: &WebhookConfig) -> Result<Self, String> {
49 let allowed_hosts = config
50 .allowed_hosts
51 .iter()
52 .map(|entry| {
53 let entry = entry.trim();
54 if entry.contains('/') {
55 IpNet::from_str(entry)
56 .map(AllowEntry::Net)
57 .map_err(|e| format!("invalid allowed_hosts entry `{entry}`: {e}"))
58 } else if let Ok(ip) = IpAddr::from_str(entry) {
59 Ok(AllowEntry::Net(IpNet::from(ip)))
60 } else if entry.is_empty() || entry.contains(char::is_whitespace) {
61 Err(format!("invalid allowed_hosts entry `{entry}`"))
62 } else {
63 Ok(AllowEntry::Host(entry.to_ascii_lowercase()))
64 }
65 })
66 .collect::<Result<Vec<_>, _>>()?;
67 Ok(Self {
68 allow_http: config.allow_http,
69 allowed_hosts,
70 })
71 }
72
73 /// True when the hostname is named in the allow list (case-insensitive).
74 pub fn host_allowed(&self, host: &str) -> bool {
75 self.allowed_hosts.iter().any(|entry| match entry {
76 AllowEntry::Host(allowed) => allowed.eq_ignore_ascii_case(host),
77 AllowEntry::Net(_) => false,
78 })
79 }
80
81 /// True when the address falls inside a CIDR network in the allow list.
82 pub fn ip_allowed(&self, ip: IpAddr) -> bool {
83 // An IPv4-mapped address matches an IPv4 network, so check both forms.
84 let mapped = ip.to_canonical();
85 self.allowed_hosts.iter().any(|entry| match entry {
86 AllowEntry::Net(net) => net.contains(&ip) || net.contains(&mapped),
87 AllowEntry::Host(_) => false,
88 })
89 }
90}
91
92/// Why a hook URL was refused.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum UrlError {
95 TooLong,
96 Parse(String),
97 Scheme(String),
98 Userinfo,
99 MissingHost,
100 BadPort,
101 Forbidden(IpAddr),
102}
103
104impl fmt::Display for UrlError {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 match self {
107 Self::TooLong => write!(f, "url is longer than {MAX_URL_LEN} bytes"),
108 Self::Parse(e) => write!(f, "invalid url: {e}"),
109 Self::Scheme(s) => write!(f, "url scheme `{s}` is not allowed"),
110 Self::Userinfo => write!(f, "url must not contain a username or password"),
111 Self::MissingHost => write!(f, "url has no host"),
112 Self::BadPort => write!(f, "url port must be between 1 and 65535"),
113 Self::Forbidden(ip) => write!(f, "destination not allowed: {ip}"),
114 }
115 }
116}
117
118impl std::error::Error for UrlError {}
119
120/// Checks a hook URL against the policy. Used when a hook is saved and again before each send.
121pub fn validate_url(url: &str, policy: &WebhookPolicy) -> Result<Url, UrlError> {
122 if url.len() > MAX_URL_LEN {
123 return Err(UrlError::TooLong);
124 }
125 let parsed = Url::parse(url).map_err(|e| UrlError::Parse(e.to_string()))?;
126 match parsed.scheme() {
127 "https" => {}
128 "http" if policy.allow_http => {}
129 other => return Err(UrlError::Scheme(other.to_owned())),
130 }
131 if !parsed.username().is_empty() || parsed.password().is_some() {
132 return Err(UrlError::Userinfo);
133 }
134 // An explicit port is parsed by `Url` (so >65535 fails above); only 0 needs rejecting here.
135 if parsed.port() == Some(0) {
136 return Err(UrlError::BadPort);
137 }
138 let host = parsed.host_str().unwrap_or_default();
139 if host.is_empty() {
140 return Err(UrlError::MissingHost);
141 }
142 // IPv6 literals come back bracketed, e.g. "[::1]".
143 let bare = host.trim_start_matches('[').trim_end_matches(']');
144 if let Ok(ip) = bare.parse::<IpAddr>() {
145 check_literal(ip, policy)?;
146 }
147 Ok(parsed)
148}
149
150fn check_literal(ip: IpAddr, policy: &WebhookPolicy) -> Result<(), UrlError> {
151 if is_forbidden_ip(ip) && !policy.ip_allowed(ip) {
152 Err(UrlError::Forbidden(ip))
153 } else {
154 Ok(())
155 }
156}
157
158/// True for addresses a webhook must not reach by default: loopback, private, link-local, CGNAT,
159/// unspecified, multicast, broadcast, documentation and reserved ranges. IPv4 addresses embedded
160/// in IPv6 (mapped, compatible, NAT64, 6to4, Teredo) are judged by the embedded IPv4 address.
161pub fn is_forbidden_ip(ip: IpAddr) -> bool {
162 match ip {
163 IpAddr::V4(v4) => is_forbidden_v4(v4),
164 IpAddr::V6(v6) => is_forbidden_v6(v6),
165 }
166}
167
168fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
169 let [a, b, c, _] = ip.octets();
170 ip.is_loopback()
171 || ip.is_private()
172 || ip.is_link_local()
173 || ip.is_unspecified()
174 || ip.is_multicast()
175 || ip.is_broadcast()
176 || ip.is_documentation()
177 || a == 0 // 0.0.0.0/8
178 || (a == 100 && (b & 0xc0) == 64) // 100.64.0.0/10 CGNAT
179 || (a == 192 && b == 0 && c == 0) // 192.0.0.0/24 IETF protocol assignments
180 || (a == 198 && (b & 0xfe) == 18) // 198.18.0.0/15 benchmarking
181 || a >= 240 // 240.0.0.0/4 reserved
182}
183
184fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
185 if let Some(v4) = ip.to_ipv4_mapped() {
186 return is_forbidden_v4(v4);
187 }
188 let s = ip.segments();
189 if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
190 return true;
191 }
192 if (s[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local
193 || (s[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
194 || (s[0] == 0x2001 && s[1] == 0x0db8) // 2001:db8::/32 documentation
195 || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0)
196 // 100::/64 discard
197 {
198 return true;
199 }
200 // IPv4-compatible ::/96 (deprecated).
201 if s[..6] == [0; 6] {
202 return is_forbidden_v4(v4_from(s[6], s[7]));
203 }
204 // NAT64 well-known prefix 64:ff9b::/96, and the local-use 64:ff9b:1::/48.
205 if s[0] == 0x0064 && s[1] == 0xff9b {
206 return s[2] == 0x0001 || (s[2..6] == [0; 4] && is_forbidden_v4(v4_from(s[6], s[7])));
207 }
208 // 6to4 2002::/16 embeds the IPv4 address in the second and third segments.
209 if s[0] == 0x2002 {
210 return is_forbidden_v4(v4_from(s[1], s[2]));
211 }
212 // Teredo 2001::/32 embeds the server address and an obfuscated client address.
213 if s[0] == 0x2001 && s[1] == 0 {
214 let client = Ipv4Addr::from(!u32::from(v4_from(s[6], s[7])));
215 return is_forbidden_v4(v4_from(s[2], s[3])) || is_forbidden_v4(client);
216 }
217 false
218}
219
220fn v4_from(hi: u16, lo: u16) -> Ipv4Addr {
221 let [a, b] = hi.to_be_bytes();
222 let [c, d] = lo.to_be_bytes();
223 Ipv4Addr::new(a, b, c, d)
224}
225
226/// Resolves hostnames and drops forbidden addresses, unless the host or the address is allowed.
227#[derive(Debug)]
228pub struct PolicyResolver {
229 policy: Arc<WebhookPolicy>,
230}
231
232impl Resolve for PolicyResolver {
233 fn resolve(&self, name: Name) -> Resolving {
234 Box::pin(resolve_allowed(
235 self.policy.clone(),
236 name.as_str().to_owned(),
237 ))
238 }
239}
240
241async fn resolve_allowed(policy: Arc<WebhookPolicy>, host: String) -> Result<Addrs, BoxError> {
242 let found: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0)).await?.collect();
243 let host_allowed = policy.host_allowed(&host);
244 let kept: Vec<SocketAddr> = found
245 .into_iter()
246 .filter(|addr| host_allowed || !is_forbidden_ip(addr.ip()) || policy.ip_allowed(addr.ip()))
247 .collect();
248 if kept.is_empty() {
249 return Err("destination not allowed".into());
250 }
251 let addrs: Addrs = Box::new(kept.into_iter());
252 Ok(addrs)
253}
254
255/// What happened when a delivery was sent. A failure leaves `status` as `None` and sets `error`.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct SendOutcome {
258 pub status: Option<u16>,
259 pub headers: Vec<(String, String)>,
260 /// Response body, at most [`MAX_BODY_BYTES`], decoded lossily as UTF-8.
261 pub body: String,
262 pub error: Option<String>,
263 pub duration_ms: u64,
264}
265
266/// Shared HTTP client for webhook deliveries. Build it once and share it behind an `Arc`.
267#[derive(Debug)]
268pub struct HookClient {
269 client: reqwest::Client,
270 policy: Arc<WebhookPolicy>,
271}
272
273impl HookClient {
274 pub fn new(policy: WebhookPolicy) -> reqwest::Result<Arc<Self>> {
275 let policy = Arc::new(policy);
276 let client = reqwest::Client::builder()
277 .redirect(reqwest::redirect::Policy::none())
278 .connect_timeout(Duration::from_secs(10))
279 .timeout(Duration::from_secs(20))
280 .dns_resolver(Arc::new(PolicyResolver {
281 policy: policy.clone(),
282 }))
283 .user_agent(concat!("forge-hookshot/", env!("CARGO_PKG_VERSION")))
284 .no_proxy()
285 .build()?;
286 Ok(Arc::new(Self { client, policy }))
287 }
288
289 /// POSTs `body` to `url`. Never follows redirects, and reads at most [`MAX_BODY_BYTES`] of the response.
290 pub async fn send(
291 &self,
292 url: &str,
293 headers: &[(String, String)],
294 body: Vec<u8>,
295 ) -> SendOutcome {
296 let start = Instant::now();
297 let parsed = match validate_url(url, &self.policy) {
298 Ok(parsed) => parsed,
299 Err(e) => return failed(e.to_string(), start),
300 };
301 let mut request = self.client.post(parsed).body(body);
302 for (name, value) in headers {
303 let name = match HeaderName::from_bytes(name.as_bytes()) {
304 Ok(name) => name,
305 Err(e) => return failed(format!("invalid header name `{name}`: {e}"), start),
306 };
307 let value = match HeaderValue::from_str(value) {
308 Ok(value) => value,
309 Err(e) => return failed(format!("invalid value for header `{name}`: {e}"), start),
310 };
311 request = request.header(name, value);
312 }
313 let response = match request.send().await {
314 Ok(response) => response,
315 Err(e) => return failed(error_chain(&e), start),
316 };
317
318 let status = response.status().as_u16();
319 let headers = response
320 .headers()
321 .iter()
322 .map(|(name, value)| {
323 (
324 name.as_str().to_owned(),
325 String::from_utf8_lossy(value.as_bytes()).into_owned(),
326 )
327 })
328 .collect();
329
330 let mut buf = Vec::new();
331 let mut error = None;
332 let mut stream = response.bytes_stream();
333 while let Some(chunk) = stream.next().await {
334 match chunk {
335 Ok(bytes) => {
336 let room = MAX_BODY_BYTES - buf.len();
337 buf.extend_from_slice(&bytes[..bytes.len().min(room)]);
338 if buf.len() >= MAX_BODY_BYTES {
339 break;
340 }
341 }
342 Err(e) => {
343 error = Some(error_chain(&e));
344 break;
345 }
346 }
347 }
348
349 SendOutcome {
350 status: Some(status),
351 headers,
352 body: String::from_utf8_lossy(&buf).into_owned(),
353 error,
354 duration_ms: elapsed_ms(start),
355 }
356 }
357}
358
359/// Signs a delivery body: `sha256=` followed by the hex HMAC-SHA256 of `body` under `secret`.
360pub fn sign(secret: &[u8], body: &[u8]) -> String {
361 // HMAC accepts keys of any length, so the error branch is unreachable.
362 let mut mac = match HmacSha256::new_from_slice(secret) {
363 Ok(mac) => mac,
364 Err(_) => return String::new(),
365 };
366 mac.update(body);
367 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
368}
369
370fn failed(error: String, start: Instant) -> SendOutcome {
371 SendOutcome {
372 status: None,
373 headers: Vec::new(),
374 body: String::new(),
375 error: Some(error),
376 duration_ms: elapsed_ms(start),
377 }
378}
379
380fn elapsed_ms(start: Instant) -> u64 {
381 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
382}
383
384/// Formats an error with its source chain, so the resolver's "destination not allowed" shows up.
385fn error_chain(err: &(dyn std::error::Error + 'static)) -> String {
386 let mut out = err.to_string();
387 let mut source = err.source();
388 while let Some(next) = source {
389 out.push_str(": ");
390 out.push_str(&next.to_string());
391 source = next.source();
392 }
393 out
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use tokio::io::{AsyncReadExt, AsyncWriteExt};
400 use tokio::net::TcpListener;
401
402 fn policy(allow_http: bool, allowed: &[&str]) -> WebhookPolicy {
403 WebhookPolicy::from_config(&WebhookConfig {
404 allow_http,
405 allowed_hosts: allowed.iter().map(|s| (*s).to_owned()).collect(),
406 })
407 .expect("valid policy")
408 }
409
410 #[test]
411 fn forbidden_ip_table() {
412 let forbidden = [
413 "127.0.0.1",
414 "10.1.2.3",
415 "172.16.0.1",
416 "192.168.1.1",
417 "169.254.169.254",
418 "100.64.0.1",
419 "0.0.0.0",
420 "::1",
421 "fe80::1",
422 "fc00::1",
423 "::ffff:127.0.0.1",
424 "64:ff9b::7f00:1",
425 "::ffff:10.0.0.1",
426 "2002:7f00:0001::1",
427 "224.0.0.1",
428 "255.255.255.255",
429 "192.0.2.1",
430 "2001:db8::1",
431 "ff02::1",
432 "240.0.0.1",
433 ];
434 for ip in forbidden {
435 let parsed: IpAddr = ip.parse().expect("ip literal");
436 assert!(is_forbidden_ip(parsed), "{ip} should be forbidden");
437 }
438 let allowed = [
439 "8.8.8.8",
440 "1.1.1.1",
441 "2001:4860:4860::8888",
442 "64:ff9b::808:808",
443 ];
444 for ip in allowed {
445 let parsed: IpAddr = ip.parse().expect("ip literal");
446 assert!(!is_forbidden_ip(parsed), "{ip} should be allowed");
447 }
448 }
449
450 #[test]
451 fn validate_url_cases() {
452 let strict = policy(false, &[]);
453 let lax = policy(true, &[]);
454 assert!(validate_url("https://hooks.example.com/a?b=1", &strict).is_ok());
455 assert!(validate_url("https://example.com:8443/x", &strict).is_ok());
456 assert!(matches!(
457 validate_url("http://example.com/", &strict),
458 Err(UrlError::Scheme(_))
459 ));
460 assert!(validate_url("http://example.com/", &lax).is_ok());
461 assert!(matches!(
462 validate_url("ftp://example.com/", &lax),
463 Err(UrlError::Scheme(_))
464 ));
465 assert_eq!(
466 validate_url("https://user:pw@example.com/", &strict),
467 Err(UrlError::Userinfo)
468 );
469 assert_eq!(
470 validate_url("https://user@example.com/", &strict),
471 Err(UrlError::Userinfo)
472 );
473 assert_eq!(
474 validate_url("https://example.com:0/", &strict),
475 Err(UrlError::BadPort)
476 );
477 assert!(matches!(
478 validate_url("https://example.com:70000/", &strict),
479 Err(UrlError::Parse(_))
480 ));
481 assert!(matches!(
482 validate_url("https://", &strict),
483 Err(UrlError::Parse(_) | UrlError::MissingHost)
484 ));
485 let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
486 assert_eq!(validate_url(&long, &strict), Err(UrlError::TooLong));
487 assert_eq!(
488 validate_url("https://127.0.0.1/", &strict),
489 Err(UrlError::Forbidden("127.0.0.1".parse().expect("ip")))
490 );
491 assert!(matches!(
492 validate_url("https://[::1]:8080/", &strict),
493 Err(UrlError::Forbidden(_))
494 ));
495 assert!(validate_url("https://8.8.8.8/", &strict).is_ok());
496 }
497
498 #[test]
499 fn allowed_hosts_cidr_and_hostname() {
500 let p = policy(false, &["Hooks.Internal", "10.20.0.0/16", "192.168.5.7"]);
501 assert!(p.host_allowed("hooks.internal"));
502 assert!(p.host_allowed("HOOKS.INTERNAL"));
503 assert!(!p.host_allowed("other.internal"));
504 assert!(p.ip_allowed("10.20.3.4".parse().expect("ip")));
505 assert!(!p.ip_allowed("10.21.0.1".parse().expect("ip")));
506 assert!(p.ip_allowed("192.168.5.7".parse().expect("ip")));
507 assert!(p.ip_allowed("::ffff:10.20.0.9".parse().expect("ip")));
508 assert!(validate_url("https://10.20.1.1/hook", &p).is_ok());
509 assert!(validate_url("https://10.30.1.1/hook", &p).is_err());
510 assert!(
511 WebhookPolicy::from_config(&WebhookConfig {
512 allow_http: false,
513 allowed_hosts: vec!["10.0.0.0/99".into()],
514 })
515 .is_err()
516 );
517 }
518
519 #[test]
520 fn sign_matches_rfc4231_case_2() {
521 // RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?".
522 assert_eq!(
523 sign(b"Jefe", b"what do ya want for nothing?"),
524 "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
525 );
526 }
527
528 /// Serves one HTTP response per connection with the given status line and body.
529 async fn serve(response: &'static [u8]) -> SocketAddr {
530 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
531 let addr = listener.local_addr().expect("addr");
532 tokio::spawn(async move {
533 loop {
534 let Ok((mut socket, _)) = listener.accept().await else {
535 return;
536 };
537 tokio::spawn(async move {
538 let mut buf = Vec::new();
539 let mut chunk = [0u8; 1024];
540 loop {
541 match socket.read(&mut chunk).await {
542 Ok(0) | Err(_) => break,
543 Ok(n) => {
544 buf.extend_from_slice(&chunk[..n]);
545 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
546 break;
547 }
548 }
549 }
550 }
551 let _ = socket.write_all(response).await;
552 let _ = socket.shutdown().await;
553 });
554 }
555 });
556 addr
557 }
558
559 fn ok_response() -> &'static [u8] {
560 b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"
561 }
562
563 async fn post(client: &HookClient, url: &str) -> SendOutcome {
564 client
565 .send(url, &[("X-Test".into(), "1".into())], b"{}".to_vec())
566 .await
567 }
568
569 #[tokio::test]
570 async fn send_rejects_loopback_by_default() {
571 let addr = serve(ok_response()).await;
572 let client = HookClient::new(policy(true, &[])).expect("client");
573 let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await;
574 assert_eq!(outcome.status, None);
575 assert!(
576 outcome
577 .error
578 .as_deref()
579 .is_some_and(|e| e.contains("destination not allowed"))
580 );
581 }
582
583 #[tokio::test]
584 async fn send_rejects_localhost_name_by_default() {
585 let addr = serve(ok_response()).await;
586 let client = HookClient::new(policy(true, &[])).expect("client");
587 let outcome = post(&client, &format!("http://localhost:{}/hook", addr.port())).await;
588 assert_eq!(outcome.status, None);
589 assert!(outcome.error.is_some());
590 }
591
592 #[tokio::test]
593 async fn send_accepts_allowed_loopback_cidr() {
594 let addr = serve(ok_response()).await;
595 let client = HookClient::new(policy(true, &["127.0.0.1/32"])).expect("client");
596 let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await;
597 assert_eq!(outcome.error, None);
598 assert_eq!(outcome.status, Some(200));
599 assert_eq!(outcome.body, "ok");
600 assert!(outcome.headers.iter().any(|(k, _)| k == "content-type"));
601 }
602
603 #[tokio::test]
604 async fn send_accepts_allowed_hostname() {
605 let addr = serve(ok_response()).await;
606 let client = HookClient::new(policy(true, &["localhost"])).expect("client");
607 let outcome = post(&client, &format!("http://localhost:{}/hook", addr.port())).await;
608 assert_eq!(outcome.error, None);
609 assert_eq!(outcome.status, Some(200));
610 }
611
612 #[tokio::test]
613 async fn send_rejects_http_when_not_allowed() {
614 let client = HookClient::new(policy(false, &["127.0.0.1/32"])).expect("client");
615 let outcome = post(&client, "http://127.0.0.1:9/hook").await;
616 assert_eq!(outcome.status, None);
617 assert!(outcome.error.is_some());
618 }
619
620 #[tokio::test]
621 async fn redirects_are_not_followed() {
622 let addr = serve(
623 b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9/elsewhere\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
624 )
625 .await;
626 let client = HookClient::new(policy(true, &["127.0.0.1/32"])).expect("client");
627 let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await;
628 assert_eq!(outcome.error, None);
629 assert_eq!(outcome.status, Some(302));
630 }
631
632 #[tokio::test]
633 async fn body_is_truncated_to_limit() {
634 let body = "x".repeat(MAX_BODY_BYTES + 50_000);
635 let head = format!(
636 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
637 body.len()
638 );
639 let mut response = head.into_bytes();
640 response.extend_from_slice(body.as_bytes());
641 let leaked: &'static [u8] = Box::leak(response.into_boxed_slice());
642 let addr = serve(leaked).await;
643 let client = HookClient::new(policy(true, &["127.0.0.1/32"])).expect("client");
644 let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await;
645 assert_eq!(outcome.status, Some(200));
646 assert_eq!(outcome.body.len(), MAX_BODY_BYTES);
647 }
648}