1use std::collections::BTreeSet;
2use std::fmt;
3use std::future::Future;
4use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
5use std::pin::Pin;
6use std::str::FromStr;
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use reqwest::Url;
11use reqwest::dns::{Addrs, Name, Resolve, Resolving};
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13use tokio::runtime::{Builder as RuntimeBuilder, Handle, Runtime};
14use tokio::sync::oneshot;
15
16pub(crate) const MAX_HTTP_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
17pub(crate) const MAX_HTTP_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024;
18pub(crate) const MAX_OPERATION_HTTP_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;
19pub(crate) const MAX_OPERATION_HTTP_RESPONSE_BODY_BYTES: usize = 32 * 1024 * 1024;
20pub(crate) const MAX_OPERATION_HTTP_REQUESTS: usize = 8;
21pub(crate) const MAX_CONCURRENT_HTTP_REQUESTS: usize = 4;
22const MAX_REDIRECTS: usize = 5;
23const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct PluginHttpRequest {
28 pub method: String,
29 pub url: String,
30 pub headers: Vec<(String, Vec<u8>)>,
31 pub body: Vec<u8>,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct PluginHttpResponse {
37 pub url: String,
38 pub status: u16,
39 pub headers: Vec<(String, Vec<u8>)>,
40 pub body: Vec<u8>,
41}
42
43pub type PluginHttpFuture<'a> =
44 Pin<Box<dyn Future<Output = Result<PluginHttpResponse, PluginHttpError>> + Send + 'a>>;
45
46pub trait PluginHttpClient: fmt::Debug + Send + Sync + 'static {
48 fn send(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_>;
49}
50
51pub trait PluginHttpTransport: fmt::Debug + Send + Sync + 'static {
56 fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_>;
57}
58
59#[derive(Clone, Copy, Debug, Default)]
61pub struct DenyPluginHttpClient;
62
63impl PluginHttpClient for DenyPluginHttpClient {
64 fn send(&self, _request: PluginHttpRequest) -> PluginHttpFuture<'_> {
65 Box::pin(async { Err(PluginHttpError::NotConfigured) })
66 }
67}
68
69#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
71pub struct PluginHttpOrigin(String);
72
73impl PluginHttpOrigin {
74 pub fn parse(value: &str) -> Result<Self, PluginHttpError> {
75 let url = Url::parse(value).map_err(|source| PluginHttpError::InvalidOrigin {
76 origin: value.to_owned(),
77 reason: source.to_string(),
78 })?;
79 let valid = url.scheme() == "https"
80 && url.host().is_some()
81 && url.username().is_empty()
82 && url.password().is_none()
83 && url.path() == "/"
84 && url.query().is_none()
85 && url.fragment().is_none();
86 if !valid {
87 return Err(PluginHttpError::InvalidOrigin {
88 origin: value.to_owned(),
89 reason: "origins must contain only an HTTPS scheme, host, and optional port"
90 .to_owned(),
91 });
92 }
93 validate_literal_host(&url)?;
94 Ok(Self(url.origin().ascii_serialization()))
95 }
96
97 #[must_use]
98 pub fn as_str(&self) -> &str {
99 &self.0
100 }
101}
102
103impl FromStr for PluginHttpOrigin {
104 type Err = PluginHttpError;
105
106 fn from_str(value: &str) -> Result<Self, Self::Err> {
107 Self::parse(value)
108 }
109}
110
111impl Serialize for PluginHttpOrigin {
112 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
113 where
114 S: Serializer,
115 {
116 serializer.serialize_str(self.as_str())
117 }
118}
119
120impl<'de> Deserialize<'de> for PluginHttpOrigin {
121 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
122 where
123 D: Deserializer<'de>,
124 {
125 let value = String::deserialize(deserializer)?;
126 Self::parse(&value).map_err(serde::de::Error::custom)
127 }
128}
129
130#[derive(Clone)]
132pub struct ScopedPluginHttpClient {
133 allowed_origins: BTreeSet<PluginHttpOrigin>,
134 transport: Arc<dyn PluginHttpTransport>,
135}
136
137impl fmt::Debug for ScopedPluginHttpClient {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 formatter
140 .debug_struct("ScopedPluginHttpClient")
141 .field("allowed_origins", &self.allowed_origins)
142 .finish_non_exhaustive()
143 }
144}
145
146impl ScopedPluginHttpClient {
147 #[must_use]
148 pub fn new(
149 allowed_origins: impl IntoIterator<Item = PluginHttpOrigin>,
150 transport: impl PluginHttpTransport,
151 ) -> Self {
152 Self {
153 allowed_origins: allowed_origins.into_iter().collect(),
154 transport: Arc::new(transport),
155 }
156 }
157
158 pub(crate) fn from_shared(
159 allowed_origins: BTreeSet<PluginHttpOrigin>,
160 transport: Arc<dyn PluginHttpTransport>,
161 ) -> Self {
162 Self {
163 allowed_origins,
164 transport,
165 }
166 }
167
168 async fn send_inner(
169 &self,
170 mut request: PluginHttpRequest,
171 ) -> Result<PluginHttpResponse, PluginHttpError> {
172 let mut redirects = 0_usize;
173 loop {
174 let current_url = self.validate_url(&request.url)?;
175 request.url = current_url.to_string();
176 let mut response = self.transport.send_once(request.clone()).await?;
177 response.url = current_url.to_string();
178 let Some(location) = redirect_location(&response)? else {
179 return Ok(response);
180 };
181 if redirects >= MAX_REDIRECTS {
182 return Err(PluginHttpError::TooManyRedirects {
183 maximum: MAX_REDIRECTS,
184 });
185 }
186 let next_url =
187 current_url
188 .join(&location)
189 .map_err(|source| PluginHttpError::InvalidRedirect {
190 location,
191 reason: source.to_string(),
192 })?;
193 let next_url = self.validate_url(next_url.as_str())?;
194 rewrite_redirect_request(&mut request, response.status);
195 if current_url.origin() != next_url.origin() {
196 remove_sensitive_headers(&mut request.headers);
197 }
198 request.url = next_url.to_string();
199 redirects += 1;
200 }
201 }
202
203 fn validate_url(&self, value: &str) -> Result<Url, PluginHttpError> {
204 let mut url = Url::parse(value).map_err(|source| PluginHttpError::InvalidUrl {
205 url: value.to_owned(),
206 reason: source.to_string(),
207 })?;
208 if url.scheme() != "https" || url.host().is_none() {
209 return Err(PluginHttpError::HttpsRequired {
210 url: value.to_owned(),
211 });
212 }
213 if !url.username().is_empty() || url.password().is_some() {
214 return Err(PluginHttpError::CredentialsInUrl {
215 url: value.to_owned(),
216 });
217 }
218 validate_literal_host(&url)?;
219 let origin = PluginHttpOrigin(url.origin().ascii_serialization());
220 if !self.allowed_origins.contains(&origin) {
221 return Err(PluginHttpError::OriginNotAllowed { origin: origin.0 });
222 }
223 url.set_fragment(None);
224 Ok(url)
225 }
226}
227
228impl PluginHttpClient for ScopedPluginHttpClient {
229 fn send(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
230 Box::pin(async move { self.send_inner(request).await })
231 }
232}
233
234#[derive(Clone)]
236pub struct ReqwestPluginHttpTransport {
237 inner: Arc<ReqwestTransportInner>,
238}
239
240impl fmt::Debug for ReqwestPluginHttpTransport {
241 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242 formatter
243 .debug_struct("ReqwestPluginHttpTransport")
244 .finish_non_exhaustive()
245 }
246}
247
248impl ReqwestPluginHttpTransport {
249 pub fn new() -> Result<Self, PluginHttpError> {
250 let runtime = RuntimeBuilder::new_multi_thread()
251 .worker_threads(2)
252 .thread_name("semifold-plugin-http")
253 .enable_all()
254 .build()
255 .map_err(|source| PluginHttpError::TransportInitialization {
256 reason: source.to_string(),
257 })?;
258 let handle = runtime.handle().clone();
259 let client = reqwest::Client::builder()
260 .redirect(reqwest::redirect::Policy::none())
261 .no_proxy()
262 .https_only(true)
263 .timeout(REQUEST_TIMEOUT)
264 .dns_resolver(Arc::new(GlobalDnsResolver))
265 .build()
266 .map_err(|source| PluginHttpError::TransportInitialization {
267 reason: source.to_string(),
268 })?;
269 Ok(Self {
270 inner: Arc::new(ReqwestTransportInner {
271 client,
272 handle,
273 runtime: Some(runtime),
274 }),
275 })
276 }
277}
278
279impl PluginHttpTransport for ReqwestPluginHttpTransport {
280 fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
281 let client = self.inner.client.clone();
282 let (sender, receiver) = oneshot::channel();
283 self.inner.handle.spawn(async move {
284 let result = send_reqwest(client, request).await;
285 let _ignored = sender.send(result);
286 });
287 Box::pin(async move {
288 receiver
289 .await
290 .map_err(|source| PluginHttpError::TransportTask {
291 reason: source.to_string(),
292 })?
293 })
294 }
295}
296
297struct ReqwestTransportInner {
298 client: reqwest::Client,
299 handle: Handle,
300 runtime: Option<Runtime>,
301}
302
303impl Drop for ReqwestTransportInner {
304 fn drop(&mut self) {
305 if let Some(runtime) = self.runtime.take() {
306 runtime.shutdown_background();
307 }
308 }
309}
310
311async fn send_reqwest(
312 client: reqwest::Client,
313 request: PluginHttpRequest,
314) -> Result<PluginHttpResponse, PluginHttpError> {
315 let target = Url::parse(&request.url).map_err(|source| PluginHttpError::InvalidUrl {
316 url: request.url.clone(),
317 reason: source.to_string(),
318 })?;
319 if target.scheme() != "https" {
320 return Err(PluginHttpError::HttpsRequired { url: request.url });
321 }
322 validate_literal_host(&target)?;
323 let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|source| {
324 PluginHttpError::InvalidMethod {
325 method: request.method.clone(),
326 reason: source.to_string(),
327 }
328 })?;
329 let mut builder = client.request(method, &request.url);
330 for (name, value) in request.headers {
331 let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|source| {
332 PluginHttpError::InvalidHeader {
333 name: name.clone(),
334 reason: source.to_string(),
335 }
336 })?;
337 let value = reqwest::header::HeaderValue::from_bytes(&value).map_err(|source| {
338 PluginHttpError::InvalidHeader {
339 name: name.as_str().to_owned(),
340 reason: source.to_string(),
341 }
342 })?;
343 builder = builder.header(name, value);
344 }
345 let mut response =
346 builder
347 .body(request.body)
348 .send()
349 .await
350 .map_err(|source| PluginHttpError::Transport {
351 reason: source.to_string(),
352 })?;
353 let url = response.url().to_string();
354 let status = response.status().as_u16();
355 let headers = response
356 .headers()
357 .iter()
358 .map(|(name, value)| (name.as_str().to_owned(), value.as_bytes().to_vec()))
359 .collect();
360 let mut body = Vec::new();
361 loop {
362 let chunk = match response.chunk().await {
363 Ok(Some(chunk)) => chunk,
364 Ok(None) => break,
365 Err(source) => {
366 return Err(PluginHttpError::TransportResponse {
367 received: body.len(),
368 reason: source.to_string(),
369 });
370 }
371 };
372 let actual = body.len().saturating_add(chunk.len());
373 if actual > MAX_HTTP_RESPONSE_BODY_BYTES {
374 return Err(PluginHttpError::ResponseBodyTooLarge {
375 actual,
376 maximum: MAX_HTTP_RESPONSE_BODY_BYTES,
377 });
378 }
379 body.extend_from_slice(&chunk);
380 }
381 Ok(PluginHttpResponse {
382 url,
383 status,
384 headers,
385 body,
386 })
387}
388
389#[derive(Clone, Copy, Debug)]
390pub(crate) struct PluginHttpLimits {
391 pub max_request_body_bytes: usize,
392 pub max_response_body_bytes: usize,
393 pub max_operation_request_body_bytes: usize,
394 pub max_operation_response_body_bytes: usize,
395 pub max_operation_requests: usize,
396 pub max_concurrent_requests: usize,
397}
398
399impl Default for PluginHttpLimits {
400 fn default() -> Self {
401 Self {
402 max_request_body_bytes: MAX_HTTP_REQUEST_BODY_BYTES,
403 max_response_body_bytes: MAX_HTTP_RESPONSE_BODY_BYTES,
404 max_operation_request_body_bytes: MAX_OPERATION_HTTP_REQUEST_BODY_BYTES,
405 max_operation_response_body_bytes: MAX_OPERATION_HTTP_RESPONSE_BODY_BYTES,
406 max_operation_requests: MAX_OPERATION_HTTP_REQUESTS,
407 max_concurrent_requests: MAX_CONCURRENT_HTTP_REQUESTS,
408 }
409 }
410}
411
412#[derive(Debug, Default)]
413struct PluginHttpBudget {
414 requests: usize,
415 concurrent_requests: usize,
416 request_body_bytes: usize,
417 response_body_bytes: usize,
418}
419
420pub(crate) struct BudgetedPluginHttpClient {
421 client: Arc<dyn PluginHttpClient>,
422 budget: Arc<Mutex<PluginHttpBudget>>,
423 limits: PluginHttpLimits,
424}
425
426impl fmt::Debug for BudgetedPluginHttpClient {
427 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
428 formatter
429 .debug_struct("BudgetedPluginHttpClient")
430 .finish_non_exhaustive()
431 }
432}
433
434impl BudgetedPluginHttpClient {
435 pub(crate) fn new(client: Arc<dyn PluginHttpClient>, limits: PluginHttpLimits) -> Self {
436 Self {
437 client,
438 budget: Arc::new(Mutex::new(PluginHttpBudget::default())),
439 limits,
440 }
441 }
442}
443
444impl PluginHttpClient for BudgetedPluginHttpClient {
445 fn send(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
446 Box::pin(async move {
447 let permit =
448 PluginHttpPermit::acquire(self.budget.clone(), self.limits, request.body.len())?;
449 match self.client.send(request).await {
450 Ok(response) => {
451 permit.finish(response.body.len())?;
452 Ok(response)
453 }
454 Err(error) => finish_failed_response(permit, error),
455 }
456 })
457 }
458}
459
460pub(crate) struct BudgetedPluginHttpTransport {
461 transport: Arc<dyn PluginHttpTransport>,
462 budget: Arc<Mutex<PluginHttpBudget>>,
463 limits: PluginHttpLimits,
464}
465
466impl fmt::Debug for BudgetedPluginHttpTransport {
467 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
468 formatter
469 .debug_struct("BudgetedPluginHttpTransport")
470 .finish_non_exhaustive()
471 }
472}
473
474impl BudgetedPluginHttpTransport {
475 pub(crate) fn new(transport: Arc<dyn PluginHttpTransport>, limits: PluginHttpLimits) -> Self {
476 Self {
477 transport,
478 budget: Arc::new(Mutex::new(PluginHttpBudget::default())),
479 limits,
480 }
481 }
482}
483
484impl PluginHttpTransport for BudgetedPluginHttpTransport {
485 fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
486 Box::pin(async move {
487 let permit =
488 PluginHttpPermit::acquire(self.budget.clone(), self.limits, request.body.len())?;
489 match self.transport.send_once(request).await {
490 Ok(response) => {
491 permit.finish(response.body.len())?;
492 Ok(response)
493 }
494 Err(error) => finish_failed_response(permit, error),
495 }
496 })
497 }
498}
499
500struct PluginHttpPermit {
501 budget: Arc<Mutex<PluginHttpBudget>>,
502 limits: PluginHttpLimits,
503 active: bool,
504}
505
506impl PluginHttpPermit {
507 fn acquire(
508 budget: Arc<Mutex<PluginHttpBudget>>,
509 limits: PluginHttpLimits,
510 request_body_bytes: usize,
511 ) -> Result<Self, PluginHttpError> {
512 if request_body_bytes > limits.max_request_body_bytes {
513 return Err(PluginHttpError::RequestBodyTooLarge {
514 actual: request_body_bytes,
515 maximum: limits.max_request_body_bytes,
516 });
517 }
518 {
519 let mut state = budget
520 .lock()
521 .map_err(|_| PluginHttpError::BudgetStateUnavailable)?;
522 let requests = state.requests.saturating_add(1);
523 if requests > limits.max_operation_requests {
524 return Err(PluginHttpError::TooManyRequests {
525 actual: requests,
526 maximum: limits.max_operation_requests,
527 });
528 }
529 let concurrent = state.concurrent_requests.saturating_add(1);
530 if concurrent > limits.max_concurrent_requests {
531 return Err(PluginHttpError::TooManyConcurrentRequests {
532 actual: concurrent,
533 maximum: limits.max_concurrent_requests,
534 });
535 }
536 let total_body = state.request_body_bytes.saturating_add(request_body_bytes);
537 if total_body > limits.max_operation_request_body_bytes {
538 return Err(PluginHttpError::OperationRequestBodyTooLarge {
539 actual: total_body,
540 maximum: limits.max_operation_request_body_bytes,
541 });
542 }
543 state.requests = requests;
544 state.concurrent_requests = concurrent;
545 state.request_body_bytes = total_body;
546 }
547 Ok(Self {
548 budget,
549 limits,
550 active: true,
551 })
552 }
553
554 fn finish(mut self, response_body_bytes: usize) -> Result<(), PluginHttpError> {
555 {
556 let mut state = self
557 .budget
558 .lock()
559 .map_err(|_| PluginHttpError::BudgetStateUnavailable)?;
560 state.concurrent_requests = state.concurrent_requests.saturating_sub(1);
561 self.active = false;
562 let total_body = state
563 .response_body_bytes
564 .saturating_add(response_body_bytes);
565 state.response_body_bytes = total_body;
566 if response_body_bytes > self.limits.max_response_body_bytes {
567 Err(PluginHttpError::ResponseBodyTooLarge {
568 actual: response_body_bytes,
569 maximum: self.limits.max_response_body_bytes,
570 })
571 } else if total_body > self.limits.max_operation_response_body_bytes {
572 Err(PluginHttpError::OperationResponseBodyTooLarge {
573 actual: total_body,
574 maximum: self.limits.max_operation_response_body_bytes,
575 })
576 } else {
577 Ok(())
578 }
579 }
580 }
581}
582
583fn finish_failed_response(
584 permit: PluginHttpPermit,
585 error: PluginHttpError,
586) -> Result<PluginHttpResponse, PluginHttpError> {
587 if let Some(received) = error.response_body_bytes() {
588 permit.finish(received)?;
589 }
590 Err(error)
591}
592
593impl Drop for PluginHttpPermit {
594 fn drop(&mut self) {
595 if !self.active {
596 return;
597 }
598 if let Ok(mut state) = self.budget.lock() {
599 state.concurrent_requests = state.concurrent_requests.saturating_sub(1);
600 }
601 }
602}
603
604#[derive(Clone, Copy, Debug)]
605struct GlobalDnsResolver;
606
607impl Resolve for GlobalDnsResolver {
608 fn resolve(&self, name: Name) -> Resolving {
609 let host = name.as_str().to_owned();
610 Box::pin(async move {
611 let addresses = tokio::net::lookup_host((host.as_str(), 0))
612 .await
613 .map_err(|source| -> Box<dyn std::error::Error + Send + Sync> {
614 Box::new(PluginHttpError::DnsResolution {
615 host: host.clone(),
616 reason: source.to_string(),
617 })
618 })?
619 .collect::<Vec<_>>();
620 if addresses.is_empty() {
621 return Err(Box::new(PluginHttpError::DnsResolution {
622 host,
623 reason: "the resolver returned no addresses".to_owned(),
624 })
625 as Box<dyn std::error::Error + Send + Sync>);
626 }
627 if let Some(address) = addresses
628 .iter()
629 .find(|address| !is_global_address(address.ip()))
630 {
631 return Err(Box::new(PluginHttpError::UnsafeAddress {
632 address: address.ip(),
633 })
634 as Box<dyn std::error::Error + Send + Sync>);
635 }
636 Ok(Box::new(addresses.into_iter()) as Addrs)
637 })
638 }
639}
640
641fn validate_literal_host(url: &Url) -> Result<(), PluginHttpError> {
642 let address = url
643 .host_str()
644 .map(|host| host.trim_start_matches('[').trim_end_matches(']'))
645 .and_then(|host| host.parse::<IpAddr>().ok());
646 if let Some(address) = address
647 && !is_global_address(address)
648 {
649 return Err(PluginHttpError::UnsafeAddress { address });
650 }
651 Ok(())
652}
653
654fn is_global_address(address: IpAddr) -> bool {
655 match address {
656 IpAddr::V4(address) => is_global_ipv4(address),
657 IpAddr::V6(address) => is_global_ipv6(address),
658 }
659}
660
661fn is_global_ipv4(address: Ipv4Addr) -> bool {
662 let [first, second, third, _fourth] = address.octets();
663 !(first == 0
664 || first == 10
665 || first == 127
666 || first >= 224
667 || first == 100 && (64..=127).contains(&second)
668 || first == 169 && second == 254
669 || first == 172 && (16..=31).contains(&second)
670 || first == 192 && second == 0 && third == 0
671 || first == 192 && second == 0 && third == 2
672 || first == 192 && second == 88 && third == 99
673 || first == 192 && second == 168
674 || first == 198 && matches!(second, 18 | 19)
675 || first == 198 && second == 51 && third == 100
676 || first == 203 && second == 0 && third == 113)
677}
678
679fn is_global_ipv6(address: Ipv6Addr) -> bool {
680 if let Some(address) = address.to_ipv4() {
681 return is_global_ipv4(address);
682 }
683 let segments = address.segments();
684 let in_global_unicast = segments[0] & 0xe000 == 0x2000;
685 let ietf_special = segments[0] == 0x2001 && segments[1] < 0x0200;
686 let documentation = segments[0] == 0x2001 && segments[1] == 0x0db8;
687 let six_to_four = segments[0] == 0x2002;
688 let documentation_v2 = segments[0] == 0x3fff && segments[1] & 0xf000 == 0;
689 in_global_unicast && !ietf_special && !documentation && !six_to_four && !documentation_v2
690}
691
692fn redirect_location(response: &PluginHttpResponse) -> Result<Option<String>, PluginHttpError> {
693 if !matches!(response.status, 301 | 302 | 303 | 307 | 308) {
694 return Ok(None);
695 }
696 let Some((_, value)) = response
697 .headers
698 .iter()
699 .find(|(name, _)| name.eq_ignore_ascii_case("location"))
700 else {
701 return Ok(None);
702 };
703 std::str::from_utf8(value)
704 .map(str::to_owned)
705 .map(Some)
706 .map_err(|source| PluginHttpError::InvalidRedirect {
707 location: String::from_utf8_lossy(value).into_owned(),
708 reason: source.to_string(),
709 })
710}
711
712fn rewrite_redirect_request(request: &mut PluginHttpRequest, status: u16) {
713 let rewrite_to_get = status == 303 && !request.method.eq_ignore_ascii_case("HEAD")
714 || matches!(status, 301 | 302) && request.method.eq_ignore_ascii_case("POST");
715 if rewrite_to_get {
716 request.method = "GET".to_owned();
717 request.body.clear();
718 request.headers.retain(|(name, _)| {
719 !matches_header(
720 name,
721 &["content-length", "content-type", "transfer-encoding"],
722 )
723 });
724 }
725}
726
727fn remove_sensitive_headers(headers: &mut Vec<(String, Vec<u8>)>) {
728 headers.retain(|(name, _)| {
729 !matches_header(name, &["authorization", "cookie", "proxy-authorization"])
730 });
731}
732
733fn matches_header(name: &str, candidates: &[&str]) -> bool {
734 candidates
735 .iter()
736 .any(|candidate| name.eq_ignore_ascii_case(candidate))
737}
738
739#[derive(Debug, thiserror::Error)]
740pub enum PluginHttpError {
741 #[error("plugin network access is not configured")]
742 NotConfigured,
743 #[error("invalid plugin HTTP origin `{origin}`: {reason}")]
744 InvalidOrigin { origin: String, reason: String },
745 #[error("invalid plugin HTTP URL `{url}`: {reason}")]
746 InvalidUrl { url: String, reason: String },
747 #[error("plugin HTTP URL must use HTTPS: `{url}`")]
748 HttpsRequired { url: String },
749 #[error("plugin HTTP URL must not contain credentials: `{url}`")]
750 CredentialsInUrl { url: String },
751 #[error("plugin HTTP origin is not allowed: `{origin}`")]
752 OriginNotAllowed { origin: String },
753 #[error("plugin HTTP target resolves to a non-global address: {address}")]
754 UnsafeAddress { address: IpAddr },
755 #[error("failed to resolve plugin HTTP host `{host}`: {reason}")]
756 DnsResolution { host: String, reason: String },
757 #[error("invalid plugin redirect location `{location}`: {reason}")]
758 InvalidRedirect { location: String, reason: String },
759 #[error("plugin HTTP request exceeded the redirect limit of {maximum}")]
760 TooManyRedirects { maximum: usize },
761 #[error("failed to initialize plugin HTTP transport: {reason}")]
762 TransportInitialization { reason: String },
763 #[error("plugin HTTP transport task failed: {reason}")]
764 TransportTask { reason: String },
765 #[error("plugin HTTP transport failed: {reason}")]
766 Transport { reason: String },
767 #[error("plugin HTTP transport failed after receiving {received} response bytes: {reason}")]
768 TransportResponse { received: usize, reason: String },
769 #[error("invalid plugin HTTP method `{method}`: {reason}")]
770 InvalidMethod { method: String, reason: String },
771 #[error("invalid plugin HTTP header `{name}`: {reason}")]
772 InvalidHeader { name: String, reason: String },
773 #[error("plugin HTTP request body contains {actual} bytes; maximum is {maximum}")]
774 RequestBodyTooLarge { actual: usize, maximum: usize },
775 #[error("plugin HTTP response body contains {actual} bytes; maximum is {maximum}")]
776 ResponseBodyTooLarge { actual: usize, maximum: usize },
777 #[error("plugin HTTP operation attempted {actual} requests; maximum is {maximum}")]
778 TooManyRequests { actual: usize, maximum: usize },
779 #[error("plugin HTTP operation attempted {actual} concurrent requests; maximum is {maximum}")]
780 TooManyConcurrentRequests { actual: usize, maximum: usize },
781 #[error("plugin HTTP operation sent {actual} request-body bytes; maximum is {maximum}")]
782 OperationRequestBodyTooLarge { actual: usize, maximum: usize },
783 #[error("plugin HTTP operation received {actual} response-body bytes; maximum is {maximum}")]
784 OperationResponseBodyTooLarge { actual: usize, maximum: usize },
785 #[error("plugin HTTP budget state is unavailable")]
786 BudgetStateUnavailable,
787 #[error("plugin HTTP backend failed: {message}")]
788 Backend { message: String },
789}
790
791impl PluginHttpError {
792 #[must_use]
793 pub fn new(message: impl Into<String>) -> Self {
794 Self::Backend {
795 message: message.into(),
796 }
797 }
798
799 fn response_body_bytes(&self) -> Option<usize> {
800 match self {
801 Self::ResponseBodyTooLarge { actual, .. } => Some(*actual),
802 Self::TransportResponse { received, .. } => Some(*received),
803 _ => None,
804 }
805 }
806}
807
808#[cfg(test)]
809mod tests {
810 use std::collections::VecDeque;
811
812 use super::*;
813
814 fn request(url: &str) -> PluginHttpRequest {
815 PluginHttpRequest {
816 method: "GET".to_owned(),
817 url: url.to_owned(),
818 headers: Vec::new(),
819 body: Vec::new(),
820 }
821 }
822
823 fn response(status: u16, location: Option<&str>, body: &[u8]) -> PluginHttpResponse {
824 PluginHttpResponse {
825 url: "https://transport.invalid/ignored".to_owned(),
826 status,
827 headers: location
828 .map(|location| vec![("location".to_owned(), location.as_bytes().to_vec())])
829 .unwrap_or_default(),
830 body: body.to_vec(),
831 }
832 }
833
834 fn block_on<T>(future: impl Future<Output = T>) -> T {
835 RuntimeBuilder::new_current_thread()
836 .enable_all()
837 .build()
838 .unwrap()
839 .block_on(future)
840 }
841
842 #[derive(Clone, Debug)]
843 struct ScriptedTransport {
844 responses: Arc<Mutex<VecDeque<PluginHttpResponse>>>,
845 requests: Arc<Mutex<Vec<PluginHttpRequest>>>,
846 }
847
848 impl ScriptedTransport {
849 fn new(responses: impl IntoIterator<Item = PluginHttpResponse>) -> Self {
850 Self {
851 responses: Arc::new(Mutex::new(responses.into_iter().collect())),
852 requests: Arc::new(Mutex::new(Vec::new())),
853 }
854 }
855 }
856
857 impl PluginHttpTransport for ScriptedTransport {
858 fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
859 Box::pin(async move {
860 self.requests.lock().unwrap().push(request);
861 self.responses
862 .lock()
863 .unwrap()
864 .pop_front()
865 .ok_or_else(|| PluginHttpError::new("scripted response queue is empty"))
866 })
867 }
868 }
869
870 #[test]
871 fn canonicalizes_exact_https_origins_and_rejects_unsafe_shapes() {
872 let origin = PluginHttpOrigin::parse("https://EXAMPLE.com:443/").unwrap();
873 assert_eq!(origin.as_str(), "https://example.com");
874 assert_eq!(
875 serde_json::to_string(&origin).unwrap(),
876 r#""https://example.com""#
877 );
878 assert_eq!(
879 serde_json::from_str::<PluginHttpOrigin>(r#""https://EXAMPLE.com:443/""#).unwrap(),
880 origin
881 );
882 assert!(matches!(
883 PluginHttpOrigin::parse("http://example.com"),
884 Err(PluginHttpError::InvalidOrigin { .. })
885 ));
886 assert!(matches!(
887 PluginHttpOrigin::parse("https://example.com/path"),
888 Err(PluginHttpError::InvalidOrigin { .. })
889 ));
890 assert!(matches!(
891 PluginHttpOrigin::parse("https://user@example.com"),
892 Err(PluginHttpError::InvalidOrigin { .. })
893 ));
894 assert!(matches!(
895 PluginHttpOrigin::parse("https://127.0.0.1"),
896 Err(PluginHttpError::UnsafeAddress { .. })
897 ));
898 assert!(matches!(
899 PluginHttpOrigin::parse("https://[::1]"),
900 Err(PluginHttpError::UnsafeAddress { .. })
901 ));
902 }
903
904 #[test]
905 fn address_policy_only_accepts_publicly_routable_targets() {
906 assert!(is_global_address("8.8.8.8".parse().unwrap()));
907 assert!(is_global_address("2606:4700:4700::1111".parse().unwrap()));
908 for address in [
909 "0.0.0.0",
910 "10.0.0.1",
911 "100.64.0.1",
912 "127.0.0.1",
913 "169.254.1.1",
914 "172.16.0.1",
915 "192.0.2.1",
916 "192.168.0.1",
917 "198.18.0.1",
918 "198.51.100.1",
919 "203.0.113.1",
920 "224.0.0.1",
921 "::1",
922 "fc00::1",
923 "fe80::1",
924 "2001:db8::1",
925 "2002:0808:0808::1",
926 ] {
927 assert!(!is_global_address(address.parse().unwrap()), "{address}");
928 }
929 }
930
931 #[test]
932 fn validates_each_redirect_and_strips_cross_origin_sensitive_headers() {
933 let transport = ScriptedTransport::new([
934 response(302, Some("https://second.example.test/final"), b""),
935 response(200, None, b"done"),
936 ]);
937 let requests = transport.requests.clone();
938 let client = ScopedPluginHttpClient::new(
939 [
940 PluginHttpOrigin::parse("https://first.example.test").unwrap(),
941 PluginHttpOrigin::parse("https://second.example.test").unwrap(),
942 ],
943 transport,
944 );
945 let mut initial = request("https://first.example.test/start#fragment");
946 initial.method = "POST".to_owned();
947 initial.body = b"payload".to_vec();
948 initial.headers = vec![
949 ("authorization".to_owned(), b"Bearer secret".to_vec()),
950 ("cookie".to_owned(), b"session=secret".to_vec()),
951 ("content-type".to_owned(), b"text/plain".to_vec()),
952 ("x-plugin".to_owned(), b"preserved".to_vec()),
953 ];
954
955 let result = block_on(client.send(initial)).unwrap();
956 assert_eq!(result.url, "https://second.example.test/final");
957 assert_eq!(result.body, b"done");
958 let requests = requests.lock().unwrap();
959 assert_eq!(requests.len(), 2);
960 assert_eq!(requests[0].url, "https://first.example.test/start");
961 assert_eq!(requests[1].method, "GET");
962 assert!(requests[1].body.is_empty());
963 assert_eq!(
964 requests[1].headers,
965 vec![("x-plugin".to_owned(), b"preserved".to_vec())]
966 );
967 }
968
969 #[test]
970 fn denies_unlisted_initial_and_redirect_origins_before_transport() {
971 let transport = ScriptedTransport::new([response(
972 302,
973 Some("https://blocked.example.test/final"),
974 b"",
975 )]);
976 let requests = transport.requests.clone();
977 let client = ScopedPluginHttpClient::new(
978 [PluginHttpOrigin::parse("https://allowed.example.test").unwrap()],
979 transport,
980 );
981
982 assert!(matches!(
983 block_on(client.send(request("https://unlisted.example.test/start"))),
984 Err(PluginHttpError::OriginNotAllowed { .. })
985 ));
986 assert!(requests.lock().unwrap().is_empty());
987 assert!(matches!(
988 block_on(client.send(request("https://allowed.example.test/start"))),
989 Err(PluginHttpError::OriginNotAllowed { .. })
990 ));
991 assert_eq!(requests.lock().unwrap().len(), 1);
992 }
993
994 #[test]
995 fn counts_redirect_exchanges_against_operation_budgets() {
996 let transport = ScriptedTransport::new([
997 response(307, Some("/next"), b""),
998 response(200, None, b"done"),
999 ]);
1000 let limits = PluginHttpLimits {
1001 max_operation_requests: 1,
1002 ..PluginHttpLimits::default()
1003 };
1004 let transport: Arc<dyn PluginHttpTransport> = Arc::new(BudgetedPluginHttpTransport::new(
1005 Arc::new(transport),
1006 limits,
1007 ));
1008 let client = ScopedPluginHttpClient::from_shared(
1009 BTreeSet::from([PluginHttpOrigin::parse("https://allowed.example.test").unwrap()]),
1010 transport,
1011 );
1012
1013 assert!(matches!(
1014 block_on(client.send(request("https://allowed.example.test/start"))),
1015 Err(PluginHttpError::TooManyRequests {
1016 actual: 2,
1017 maximum: 1
1018 })
1019 ));
1020 }
1021
1022 #[test]
1023 fn stops_after_five_followed_redirects() {
1024 let transport =
1025 ScriptedTransport::new((0..=MAX_REDIRECTS).map(|_| response(307, Some("/next"), b"")));
1026 let requests = transport.requests.clone();
1027 let client = ScopedPluginHttpClient::new(
1028 [PluginHttpOrigin::parse("https://allowed.example.test").unwrap()],
1029 transport,
1030 );
1031
1032 assert!(matches!(
1033 block_on(client.send(request("https://allowed.example.test/start"))),
1034 Err(PluginHttpError::TooManyRedirects {
1035 maximum: MAX_REDIRECTS
1036 })
1037 ));
1038 assert_eq!(requests.lock().unwrap().len(), MAX_REDIRECTS + 1);
1039 }
1040
1041 #[test]
1042 fn enforces_request_response_and_concurrency_budgets() {
1043 let limits = PluginHttpLimits {
1044 max_request_body_bytes: 8,
1045 max_response_body_bytes: 8,
1046 max_operation_request_body_bytes: 10,
1047 max_operation_response_body_bytes: 10,
1048 max_operation_requests: 8,
1049 max_concurrent_requests: 4,
1050 };
1051 let budget = Arc::new(Mutex::new(PluginHttpBudget::default()));
1052 let permits = (0..4)
1053 .map(|_| PluginHttpPermit::acquire(budget.clone(), limits, 2).unwrap())
1054 .collect::<Vec<_>>();
1055 assert!(matches!(
1056 PluginHttpPermit::acquire(budget.clone(), limits, 1),
1057 Err(PluginHttpError::TooManyConcurrentRequests {
1058 actual: 5,
1059 maximum: 4
1060 })
1061 ));
1062 drop(permits);
1063
1064 let permit = PluginHttpPermit::acquire(budget.clone(), limits, 2).unwrap();
1065 assert!(matches!(
1066 permit.finish(9),
1067 Err(PluginHttpError::ResponseBodyTooLarge {
1068 actual: 9,
1069 maximum: 8
1070 })
1071 ));
1072 let permit = PluginHttpPermit::acquire(budget.clone(), limits, 0).unwrap();
1073 assert!(matches!(
1074 permit.finish(2),
1075 Err(PluginHttpError::OperationResponseBodyTooLarge {
1076 actual: 11,
1077 maximum: 10
1078 })
1079 ));
1080 assert!(matches!(
1081 PluginHttpPermit::acquire(budget, limits, 9),
1082 Err(PluginHttpError::RequestBodyTooLarge {
1083 actual: 9,
1084 maximum: 8
1085 })
1086 ));
1087 }
1088
1089 #[test]
1090 fn reqwest_transport_is_send_sync_and_safe_to_drop_inside_async_context() {
1091 fn assert_send_sync<T: Send + Sync>() {}
1092 assert_send_sync::<ReqwestPluginHttpTransport>();
1093
1094 block_on(async {
1095 let transport = ReqwestPluginHttpTransport::new().unwrap();
1096 drop(transport);
1097 });
1098 }
1099}