1use std::io::{Read, Write};
17use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs};
18use std::sync::{Arc, OnceLock};
19use std::time::{Duration, Instant};
20
21pub const IO_TIMEOUT: Duration = Duration::from_secs(10);
23const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(30);
25const MAX_RESPONSE: usize = 64 * 1024;
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct SendError {
31 pub message: String,
32 pub retryable: bool,
33}
34
35impl SendError {
36 pub fn permanent(m: impl Into<String>) -> SendError {
37 SendError {
38 message: m.into(),
39 retryable: false,
40 }
41 }
42 pub fn transient(m: impl Into<String>) -> SendError {
43 SendError {
44 message: m.into(),
45 retryable: true,
46 }
47 }
48}
49
50impl std::fmt::Display for SendError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(&self.message)
53 }
54}
55
56#[derive(Clone)]
58pub struct Net {
59 pub allow_private: bool,
61 pub tls: Arc<rustls::ClientConfig>,
62}
63
64impl Net {
65 pub fn new(allow_private: bool) -> Net {
67 Net {
68 allow_private,
69 tls: default_tls(),
70 }
71 }
72}
73
74pub fn default_tls() -> Arc<rustls::ClientConfig> {
76 static TLS: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
77 TLS.get_or_init(|| {
78 let roots = rustls::RootCertStore {
79 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
80 };
81 tls_with_roots(roots)
82 })
83 .clone()
84}
85
86pub fn tls_with_roots(roots: rustls::RootCertStore) -> Arc<rustls::ClientConfig> {
88 let provider = Arc::new(rustls::crypto::ring::default_provider());
89 Arc::new(
90 rustls::ClientConfig::builder_with_provider(provider)
91 .with_safe_default_protocol_versions()
92 .expect("ring supports the default protocol versions")
93 .with_root_certificates(roots)
94 .with_no_client_auth(),
95 )
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Target {
101 pub https: bool,
102 pub host: String,
104 pub port: u16,
105 pub path: String,
107}
108
109impl Target {
110 fn host_header(&self) -> String {
112 let h = if self.host.contains(':') {
113 format!("[{}]", self.host)
114 } else {
115 self.host.clone()
116 };
117 let default = if self.https { 443 } else { 80 };
118 if self.port == default {
119 h
120 } else {
121 format!("{h}:{}", self.port)
122 }
123 }
124}
125
126pub fn parse_url(s: &str) -> Result<Target, String> {
128 let s = s.trim();
129 let (https, rest) = if let Some(r) = strip_prefix_ci(s, "https://") {
130 (true, r)
131 } else if let Some(r) = strip_prefix_ci(s, "http://") {
132 (false, r)
133 } else {
134 return Err("the URL must start with http:// or https://".into());
135 };
136 if s.chars().any(|c| c.is_whitespace() || c.is_control()) {
137 return Err("the URL holds whitespace or control characters".into());
138 }
139 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
140 let (authority, tail) = rest.split_at(end);
141 if authority.contains('@') {
142 return Err("credentials in the URL are not supported".into());
143 }
144 let (host, port) = if let Some(r) = authority.strip_prefix('[') {
145 let close = r.find(']').ok_or("an unclosed [ in the URL's host")?;
146 let host = &r[..close];
147 host.parse::<Ipv6Addr>()
148 .map_err(|_| "a bad IPv6 address in the URL")?;
149 let after = &r[close + 1..];
150 let port = match after.strip_prefix(':') {
151 Some(p) => Some(p),
152 None if after.is_empty() => None,
153 None => return Err("junk after the URL's IPv6 host".into()),
154 };
155 (host.to_ascii_lowercase(), port)
156 } else {
157 match authority.rsplit_once(':') {
158 Some((h, p)) => (h.to_ascii_lowercase(), Some(p)),
159 None => (authority.to_ascii_lowercase(), None),
160 }
161 };
162 if host.is_empty() {
163 return Err("the URL has no host".into());
164 }
165 if !host.contains(':')
166 && !host
167 .chars()
168 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
169 {
170 return Err(format!("{host:?} is not a host name"));
171 }
172 let port = match port {
173 Some(p) => p
174 .parse::<u16>()
175 .ok()
176 .filter(|p| *p != 0)
177 .ok_or_else(|| format!("a bad port in the URL for {host}"))?,
178 None if https => 443,
179 None => 80,
180 };
181 let tail = tail.split('#').next().unwrap_or_default();
182 let path = if tail.starts_with('/') {
183 tail.to_string()
184 } else {
185 format!("/{tail}")
186 };
187 Ok(Target {
188 https,
189 host,
190 port,
191 path,
192 })
193}
194
195fn strip_prefix_ci<'a>(s: &'a str, p: &str) -> Option<&'a str> {
196 (s.len() >= p.len() && s[..p.len()].eq_ignore_ascii_case(p)).then(|| &s[p.len()..])
197}
198
199pub fn parse_inet_aton(s: &str) -> Option<Ipv4Addr> {
203 let parts: Vec<&str> = s.split('.').collect();
204 if parts.is_empty() || parts.len() > 4 {
205 return None;
206 }
207 let mut nums = Vec::with_capacity(4);
208 for p in &parts {
209 let (digits, radix) = if let Some(h) = p.strip_prefix("0x").or(p.strip_prefix("0X")) {
210 (h, 16)
211 } else if p.len() > 1 && p.starts_with('0') {
212 (&p[1..], 8)
213 } else {
214 (*p, 10)
215 };
216 if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
217 return None;
218 }
219 let n = u64::from_str_radix(digits, radix).ok()?;
220 nums.push(n);
221 }
222 let last = *nums.last()?;
223 let lead = &nums[..nums.len() - 1];
224 if lead.iter().any(|n| *n > 255) {
225 return None;
226 }
227 let rest_bits = 8 * (4 - lead.len() as u32);
228 if rest_bits < 64 && last >= (1u64 << rest_bits) {
229 return None;
230 }
231 let mut v: u64 = 0;
232 for (i, n) in lead.iter().enumerate() {
233 v |= n << (24 - 8 * i as u32);
234 }
235 Some(Ipv4Addr::from((v | last) as u32))
236}
237
238pub fn check_ip(ip: IpAddr, allow_private: bool) -> Result<(), String> {
242 match classify(ip) {
243 Class::Public => Ok(()),
244 Class::Never(why) => Err(format!("{ip} is {why}")),
245 Class::Private(_) if allow_private => Ok(()),
246 Class::Private(why) => Err(format!(
247 "{ip} is {why}; private targets are off (a platform admin can allow them)"
248 )),
249 }
250}
251
252enum Class {
253 Public,
254 Private(&'static str),
255 Never(&'static str),
256}
257
258fn classify(ip: IpAddr) -> Class {
259 match ip {
260 IpAddr::V4(v4) => classify_v4(v4),
261 IpAddr::V6(v6) => classify_v6(v6),
262 }
263}
264
265fn in_v4(ip: Ipv4Addr, net: [u8; 4], bits: u32) -> bool {
266 let mask = if bits == 0 {
267 0
268 } else {
269 u32::MAX << (32 - bits)
270 };
271 (u32::from(ip) & mask) == (u32::from(Ipv4Addr::from(net)) & mask)
272}
273
274fn classify_v4(ip: Ipv4Addr) -> Class {
275 use Class::*;
276 let table: &[([u8; 4], u32, Class)] = &[
277 ([0, 0, 0, 0], 8, Never("in 0.0.0.0/8 (this network)")),
278 ([255, 255, 255, 255], 32, Never("the broadcast address")),
279 ([224, 0, 0, 0], 4, Never("multicast")),
280 ([127, 0, 0, 0], 8, Private("loopback")),
281 ([10, 0, 0, 0], 8, Private("private (10.0.0.0/8)")),
282 ([172, 16, 0, 0], 12, Private("private (172.16.0.0/12)")),
283 ([192, 168, 0, 0], 16, Private("private (192.168.0.0/16)")),
284 (
285 [100, 64, 0, 0],
286 10,
287 Private("shared address space (100.64.0.0/10)"),
288 ),
289 ([169, 254, 0, 0], 16, Private("link-local")),
290 ([192, 0, 0, 0], 24, Private("IETF protocol space")),
291 ([192, 0, 2, 0], 24, Private("documentation space")),
292 ([198, 51, 100, 0], 24, Private("documentation space")),
293 ([203, 0, 113, 0], 24, Private("documentation space")),
294 ([198, 18, 0, 0], 15, Private("benchmarking space")),
295 ([240, 0, 0, 0], 4, Private("reserved (240.0.0.0/4)")),
296 ];
297 for (net, bits, class) in table {
298 if in_v4(ip, *net, *bits) {
299 return match class {
300 Never(w) => Never(w),
301 Private(w) => Private(w),
302 Public => Public,
303 };
304 }
305 }
306 Public
307}
308
309fn classify_v6(ip: Ipv6Addr) -> Class {
310 use Class::*;
311 let s = ip.segments();
312 let embedded = |hi: usize| {
313 Ipv4Addr::new(
314 (s[hi] >> 8) as u8,
315 s[hi] as u8,
316 (s[hi + 1] >> 8) as u8,
317 s[hi + 1] as u8,
318 )
319 };
320 if ip.is_unspecified() {
321 return Never("unspecified");
322 }
323 if ip.is_loopback() {
324 return Private("loopback");
325 }
326 if s[0] & 0xff00 == 0xff00 {
327 return Never("multicast");
328 }
329 if s[..5] == [0; 5] && (s[5] == 0xffff || s[5] == 0) {
331 return classify_v4(embedded(6));
332 }
333 if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
335 return classify_v4(embedded(6));
336 }
337 if s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 {
338 return Private("local-use NAT64");
339 }
340 if s[0] == 0x2002 {
342 return classify_v4(embedded(1));
343 }
344 if s[0] == 0x2001 && s[1] == 0 {
345 return Private("Teredo");
346 }
347 if s[0] == 0x2001 && s[1] == 0x0db8 {
348 return Private("documentation space");
349 }
350 if s[0] & 0xfe00 == 0xfc00 {
351 return Private("unique local (fc00::/7)");
352 }
353 if s[0] & 0xffc0 == 0xfe80 {
354 return Private("link-local");
355 }
356 if s[0] & 0xffc0 == 0xfec0 {
357 return Private("site-local");
358 }
359 if s[..4] == [0x100, 0, 0, 0] {
360 return Private("discard space");
361 }
362 Public
363}
364
365pub fn resolve(host: &str, port: u16, allow_private: bool) -> Result<Vec<SocketAddr>, SendError> {
368 let literal = host
369 .parse::<IpAddr>()
370 .ok()
371 .or_else(|| parse_inet_aton(host).map(IpAddr::V4));
372 let addrs: Vec<SocketAddr> = match literal {
373 Some(ip) => vec![SocketAddr::new(ip, port)],
374 None => (host, port)
375 .to_socket_addrs()
376 .map_err(|e| SendError::transient(format!("cannot resolve {host}: {e}")))?
377 .collect(),
378 };
379 if addrs.is_empty() {
380 return Err(SendError::transient(format!("{host} has no addresses")));
381 }
382 for a in &addrs {
383 check_ip(a.ip(), allow_private)
384 .map_err(|why| SendError::permanent(format!("refusing {host}: {why}")))?;
385 }
386 Ok(addrs)
387}
388
389pub fn connect(host: &str, port: u16, allow_private: bool) -> Result<TcpStream, SendError> {
391 let addrs = resolve(host, port, allow_private)?;
392 let mut last = None;
393 for a in addrs {
394 match TcpStream::connect_timeout(&a, IO_TIMEOUT) {
395 Ok(s) => {
396 let peer = s
397 .peer_addr()
398 .map_err(|e| SendError::transient(format!("{host}: {e}")))?;
399 check_ip(peer.ip(), allow_private)
400 .map_err(|why| SendError::permanent(format!("refusing {host}: {why}")))?;
401 s.set_read_timeout(Some(IO_TIMEOUT)).ok();
402 s.set_write_timeout(Some(IO_TIMEOUT)).ok();
403 return Ok(s);
404 }
405 Err(e) => last = Some(e),
406 }
407 }
408 Err(SendError::transient(format!(
409 "cannot connect to {host}:{port}: {}",
410 last.map(|e| e.to_string()).unwrap_or_default()
411 )))
412}
413
414pub fn tls(
416 net: &Net,
417 host: &str,
418 tcp: TcpStream,
419) -> Result<rustls::StreamOwned<rustls::ClientConnection, TcpStream>, SendError> {
420 let name = rustls::pki_types::ServerName::try_from(host.to_string())
421 .map_err(|_| SendError::permanent(format!("{host} is not a TLS server name")))?;
422 let conn = rustls::ClientConnection::new(net.tls.clone(), name)
423 .map_err(|e| SendError::permanent(format!("TLS to {host}: {e}")))?;
424 Ok(rustls::StreamOwned::new(conn, tcp))
425}
426
427#[derive(Debug, Clone)]
429pub struct Request {
430 pub url: String,
431 pub headers: Vec<(String, String)>,
432 pub body: Vec<u8>,
433}
434
435#[derive(Debug, Clone)]
437pub struct Response {
438 pub status: u16,
439 pub retry_after: Option<Duration>,
440 pub body: String,
442}
443
444pub fn post(net: &Net, req: &Request) -> Result<Response, SendError> {
446 let t = parse_url(&req.url).map_err(SendError::permanent)?;
447 let started = Instant::now();
448 let tcp = connect(&t.host, t.port, net.allow_private)?;
449 let mut head = format!(
450 "POST {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: isb/{}\r\nContent-Length: {}\r\nConnection: close\r\n",
451 t.path,
452 t.host_header(),
453 env!("CARGO_PKG_VERSION"),
454 req.body.len()
455 );
456 for (k, v) in &req.headers {
457 if k.contains(['\r', '\n', ':']) || v.contains(['\r', '\n']) {
458 return Err(SendError::permanent(format!("a bad header {k:?}")));
459 }
460 head.push_str(&format!("{k}: {v}\r\n"));
461 }
462 head.push_str("\r\n");
463 let raw = if t.https {
464 let mut s = tls(net, &t.host, tcp)?;
465 exchange(&mut s, &t.host, head.as_bytes(), &req.body, started)?
466 } else {
467 let mut s = tcp;
468 exchange(&mut s, &t.host, head.as_bytes(), &req.body, started)?
469 };
470 parse_response(&raw).map_err(|e| SendError::transient(format!("{}: {e}", t.host)))
471}
472
473fn exchange<S: Read + Write>(
474 s: &mut S,
475 host: &str,
476 head: &[u8],
477 body: &[u8],
478 started: Instant,
479) -> Result<Vec<u8>, SendError> {
480 let io = |e: std::io::Error| SendError::transient(format!("{host}: {e}"));
481 s.write_all(head).map_err(io)?;
482 s.write_all(body).map_err(io)?;
483 s.flush().map_err(io)?;
484 let mut out = Vec::new();
485 let mut buf = [0u8; 8192];
486 loop {
487 if started.elapsed() > EXCHANGE_TIMEOUT {
488 return Err(SendError::transient(format!(
489 "{host}: no complete answer within {EXCHANGE_TIMEOUT:?}"
490 )));
491 }
492 match s.read(&mut buf) {
493 Ok(0) => break,
494 Ok(n) => {
495 out.extend_from_slice(&buf[..n]);
496 if out.len() >= MAX_RESPONSE || response_complete(&out) {
497 break;
498 }
499 }
500 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof && !out.is_empty() => break,
502 Err(e) if !out.is_empty() && header_end(&out).is_some() => {
503 let _ = e;
504 break;
505 }
506 Err(e) => return Err(io(e)),
507 }
508 }
509 Ok(out)
510}
511
512fn header_end(b: &[u8]) -> Option<usize> {
513 b.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
514}
515
516fn response_complete(b: &[u8]) -> bool {
519 let Some(end) = header_end(b) else {
520 return false;
521 };
522 let mut headers = [httparse::EMPTY_HEADER; 64];
523 let mut r = httparse::Response::new(&mut headers);
524 if r.parse(b).is_err() {
525 return false;
526 }
527 let len = r
528 .headers
529 .iter()
530 .find(|h| h.name.eq_ignore_ascii_case("content-length"))
531 .and_then(|h| {
532 std::str::from_utf8(h.value)
533 .ok()?
534 .trim()
535 .parse::<usize>()
536 .ok()
537 });
538 matches!(len, Some(n) if b.len() >= end + n)
539}
540
541fn parse_response(raw: &[u8]) -> Result<Response, String> {
542 let mut headers = [httparse::EMPTY_HEADER; 64];
543 let mut r = httparse::Response::new(&mut headers);
544 let end = match r.parse(raw) {
545 Ok(httparse::Status::Complete(n)) => n,
546 Ok(httparse::Status::Partial) => return Err("an incomplete HTTP answer".into()),
547 Err(e) => return Err(format!("a bad HTTP answer: {e}")),
548 };
549 let status = r.code.unwrap_or(0);
550 let retry_after = r
551 .headers
552 .iter()
553 .find(|h| h.name.eq_ignore_ascii_case("retry-after"))
554 .and_then(|h| {
555 std::str::from_utf8(h.value)
556 .ok()?
557 .trim()
558 .parse::<u64>()
559 .ok()
560 })
561 .map(Duration::from_secs);
562 let body = &raw[end..];
563 let body = String::from_utf8_lossy(&body[..body.len().min(512)]).into_owned();
564 Ok(Response {
565 status,
566 retry_after,
567 body,
568 })
569}
570
571pub fn hmac_sha256_hex(key: &[u8], body: &[u8]) -> String {
573 let k = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, key);
574 ring::hmac::sign(&k, body)
575 .as_ref()
576 .iter()
577 .map(|b| format!("{b:02x}"))
578 .collect()
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 fn refused(s: &str) -> bool {
586 let ip: IpAddr = s.parse().unwrap();
587 check_ip(ip, false).is_err()
588 }
589
590 #[test]
591 fn urls() {
592 let t = parse_url("https://hooks.slack.com/services/T/B/x?y=1#frag").unwrap();
593 assert_eq!(
594 t,
595 Target {
596 https: true,
597 host: "hooks.slack.com".into(),
598 port: 443,
599 path: "/services/T/B/x?y=1".into()
600 }
601 );
602 let t = parse_url("HTTP://[::1]:8080").unwrap();
603 assert_eq!(
604 (t.https, t.host.as_str(), t.port, t.path.as_str()),
605 (false, "::1", 8080, "/")
606 );
607 assert_eq!(t.host_header(), "[::1]:8080");
608 assert_eq!(
609 parse_url("http://Example.COM:80/a").unwrap().host_header(),
610 "example.com"
611 );
612 for bad in [
613 "ftp://x/",
614 "file:///etc/passwd",
615 "gopher://x",
616 "http://user:pw@x/",
617 "http:///x",
618 "http://x:0/",
619 "http://x:99999/",
620 "http://[::1/",
621 "http://a b/",
622 "http://x/\r\nHost: y",
623 ] {
624 assert!(parse_url(bad).is_err(), "{bad}");
625 }
626 let e = parse_url("ftp://x/SECRETTOKEN").unwrap_err();
628 assert!(!e.contains("SECRETTOKEN"));
629 }
630
631 #[test]
632 fn non_public_ranges_are_refused() {
633 for s in [
634 "127.0.0.1",
635 "127.255.0.9",
636 "10.1.2.3",
637 "172.16.0.1",
638 "172.31.255.255",
639 "192.168.1.1",
640 "169.254.169.254",
641 "100.64.0.1",
642 "100.86.22.100",
643 "0.0.0.0",
644 "0.1.2.3",
645 "255.255.255.255",
646 "224.0.0.1",
647 "240.0.0.1",
648 "198.18.0.1",
649 "::",
650 "::1",
651 "fe80::1",
652 "fc00::1",
653 "fd42:1:2::3",
654 "ff02::1",
655 "::ffff:127.0.0.1",
656 "::ffff:10.0.0.1",
657 "::127.0.0.1",
658 "64:ff9b::a9fe:a9fe",
659 "2002:7f00:1::",
660 "2002:c0a8:0101::1",
661 "2001:db8::1",
662 "fec0::1",
663 ] {
664 assert!(refused(s), "{s} should be refused");
665 }
666 for s in [
667 "1.1.1.1",
668 "8.8.8.8",
669 "172.32.0.1",
670 "100.128.0.1",
671 "2606:4700:4700::1111",
672 "::ffff:1.1.1.1",
673 "64:ff9b::808:808",
674 "2002:0808:0808::1",
675 ] {
676 assert!(!refused(s), "{s} should pass");
677 }
678 assert!(check_ip("127.0.0.1".parse().unwrap(), true).is_ok());
680 assert!(check_ip("::1".parse().unwrap(), true).is_ok());
681 assert!(check_ip("0.0.0.0".parse().unwrap(), true).is_err());
682 assert!(check_ip("::".parse().unwrap(), true).is_err());
683 assert!(check_ip("ff02::1".parse().unwrap(), true).is_err());
684 }
685
686 #[test]
687 fn numeric_host_forms() {
688 let lo = Ipv4Addr::new(127, 0, 0, 1);
689 for s in [
690 "2130706433",
691 "0x7f000001",
692 "0X7F000001",
693 "017700000001",
694 "0177.0.0.1",
695 "0x7f.0.0.1",
696 "127.1",
697 "127.0.1",
698 "0x7f.1",
699 ] {
700 assert_eq!(parse_inet_aton(s), Some(lo), "{s}");
701 }
702 assert_eq!(
703 parse_inet_aton("169.254.43518"),
704 Some(Ipv4Addr::new(169, 254, 169, 254))
705 );
706 for s in [
707 "example.com",
708 "256.1.1.1",
709 "1.2.3.4.5",
710 "",
711 "0x",
712 "09",
713 "1.2.3.256",
714 ] {
715 assert_eq!(parse_inet_aton(s), None, "{s}");
716 }
717 for h in [
719 "2130706433",
720 "0x7f000001",
721 "0177.0.0.1",
722 "127.1",
723 "::ffff:127.0.0.1",
724 ] {
725 let e = resolve(h, 80, false).unwrap_err();
726 assert!(!e.retryable && e.message.contains("loopback"), "{h}: {e}");
727 }
728 assert!(resolve("2130706433", 80, true).is_ok());
729 }
730
731 #[test]
732 fn names_resolving_to_private_addresses_are_refused() {
733 let e = resolve("localhost", 80, false).unwrap_err();
736 assert!(e.message.contains("loopback"), "{e}");
737 assert!(!e.retryable);
738 let e = connect("localhost", 9, false).unwrap_err();
740 assert!(e.message.contains("refusing localhost"), "{e}");
741 }
742
743 #[test]
744 fn posts_without_following_redirects() {
745 use std::net::TcpListener;
746 let l = TcpListener::bind("127.0.0.1:0").unwrap();
747 let port = l.local_addr().unwrap().port();
748 let h = std::thread::spawn(move || {
749 let (mut s, _) = l.accept().unwrap();
750 let mut buf = vec![0u8; 4096];
751 let mut got = Vec::new();
752 while !String::from_utf8_lossy(&got).contains("hello") {
753 let n = s.read(&mut buf).unwrap();
754 got.extend_from_slice(&buf[..n]);
755 }
756 s.write_all(
757 b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/\r\nRetry-After: 7\r\nContent-Length: 2\r\n\r\nno",
758 )
759 .unwrap();
760 String::from_utf8(got).unwrap()
761 });
762 let net = Net::new(true);
763 let r = post(
764 &net,
765 &Request {
766 url: format!("http://127.0.0.1:{port}/hook?a=1"),
767 headers: vec![("X-Test".into(), "1".into())],
768 body: b"hello".to_vec(),
769 },
770 )
771 .unwrap();
772 assert_eq!(r.status, 302);
773 assert_eq!(r.retry_after, Some(Duration::from_secs(7)));
774 assert_eq!(r.body, "no");
775 let req = h.join().unwrap();
776 assert!(req.starts_with("POST /hook?a=1 HTTP/1.1\r\n"), "{req}");
777 assert!(req.contains(&format!("Host: 127.0.0.1:{port}\r\n")));
778 assert!(req.contains("X-Test: 1\r\n"));
779 let e = post(
781 &Net::new(false),
782 &Request {
783 url: format!("http://127.0.0.1:{port}/hook"),
784 headers: vec![],
785 body: vec![],
786 },
787 )
788 .unwrap_err();
789 assert!(e.message.contains("private targets are off"), "{e}");
790 }
791
792 #[test]
793 fn hmac_matches_rfc_4231() {
794 assert_eq!(
796 hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?"),
797 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
798 );
799 }
800}