Skip to main content

semifold_resolver/plugin/
http.rs

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/// Runtime-neutral request passed to a Semifold-controlled HTTP client or transport.
26#[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/// Runtime-neutral response returned by a Semifold-controlled HTTP client or transport.
35#[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
46/// Complete network capability consumed by the Boa `fetch` implementation.
47pub trait PluginHttpClient: fmt::Debug + Send + Sync + 'static {
48    fn send(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_>;
49}
50
51/// One HTTP exchange with redirects disabled.
52///
53/// Implementations must not use ambient proxies or credentials. A concrete network transport must
54/// also prevent unsafe resolved addresses from reaching its connector.
55pub trait PluginHttpTransport: fmt::Debug + Send + Sync + 'static {
56    fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_>;
57}
58
59/// Default network capability. Plugins cannot access the network unless the host injects a client.
60#[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/// Canonical exact HTTPS origin accepted by a plugin definition.
70#[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/// Client that enforces exact HTTPS origins and validates each redirect before another exchange.
131#[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/// Async reqwest transport backed by its own reusable Tokio runtime.
235#[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}