|
| 1 | +use anyhow::Result; |
| 2 | +use http::HeaderMap; |
| 3 | +use reqwest::{Client, Url}; |
| 4 | +use spin_core::async_trait; |
| 5 | +use spin_world::v1::{ |
| 6 | + http as outbound_http, |
| 7 | + http_types::{Headers, HttpError, Method, Request, Response}, |
| 8 | +}; |
| 9 | + |
| 10 | +use crate::allowed_http_hosts::AllowedHttpHosts; |
| 11 | + |
| 12 | +/// A very simple implementation for outbound HTTP requests. |
| 13 | +#[derive(Default, Clone)] |
| 14 | +pub struct OutboundHttp { |
| 15 | + /// List of hosts guest modules are allowed to make requests to. |
| 16 | + pub allowed_hosts: AllowedHttpHosts, |
| 17 | + /// During an incoming HTTP request, origin is set to the host of that incoming HTTP request. |
| 18 | + /// This is used to direct outbound requests to the same host when allowed. |
| 19 | + pub origin: String, |
| 20 | + client: Option<Client>, |
| 21 | +} |
| 22 | + |
| 23 | +impl OutboundHttp { |
| 24 | + /// Check if guest module is allowed to send request to URL, based on the list of |
| 25 | + /// allowed hosts defined by the runtime. If the url passed in is a relative path, |
| 26 | + /// only allow if allowed_hosts contains `self`. If the list of allowed hosts contains |
| 27 | + /// `insecure:allow-all`, then all hosts are allowed. |
| 28 | + /// If `None` is passed, the guest module is not allowed to send the request. |
| 29 | + fn is_allowed(&mut self, url: &str) -> Result<bool, HttpError> { |
| 30 | + if url.starts_with('/') { |
| 31 | + return Ok(self.allowed_hosts.allow_relative_url()); |
| 32 | + } |
| 33 | + |
| 34 | + let url = Url::parse(url).map_err(|_| HttpError::InvalidUrl)?; |
| 35 | + Ok(self.allowed_hosts.allow(&url)) |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +#[async_trait] |
| 40 | +impl outbound_http::Host for OutboundHttp { |
| 41 | + async fn send_request(&mut self, req: Request) -> Result<Result<Response, HttpError>> { |
| 42 | + Ok(async { |
| 43 | + tracing::log::trace!("Attempting to send outbound HTTP request to {}", req.uri); |
| 44 | + if !self |
| 45 | + .is_allowed(&req.uri) |
| 46 | + .map_err(|_| HttpError::RuntimeError)? |
| 47 | + { |
| 48 | + tracing::log::info!("Destination not allowed: {}", req.uri); |
| 49 | + if let Some(host) = host(&req.uri) { |
| 50 | + terminal::warn!("A component tried to make a HTTP request to non-allowed host '{host}'."); |
| 51 | + eprintln!("To allow requests, add 'allowed_http_hosts = [\"{host}\"]' to the manifest component section."); |
| 52 | + } |
| 53 | + return Err(HttpError::DestinationNotAllowed); |
| 54 | + } |
| 55 | + |
| 56 | + let method = method_from(req.method); |
| 57 | + |
| 58 | + let abs_url = if req.uri.starts_with('/') { |
| 59 | + format!("{}{}", self.origin, req.uri) |
| 60 | + } else { |
| 61 | + req.uri.clone() |
| 62 | + }; |
| 63 | + |
| 64 | + let req_url = Url::parse(&abs_url).map_err(|_| HttpError::InvalidUrl)?; |
| 65 | + |
| 66 | + let headers = request_headers(req.headers).map_err(|_| HttpError::RuntimeError)?; |
| 67 | + let body = req.body.unwrap_or_default().to_vec(); |
| 68 | + |
| 69 | + if !req.params.is_empty() { |
| 70 | + tracing::log::warn!("HTTP params field is deprecated"); |
| 71 | + } |
| 72 | + |
| 73 | + // Allow reuse of Client's internal connection pool for multiple requests |
| 74 | + // in a single component execution |
| 75 | + let client = self.client.get_or_insert_with(Default::default); |
| 76 | + |
| 77 | + let resp = client |
| 78 | + .request(method, req_url) |
| 79 | + .headers(headers) |
| 80 | + .body(body) |
| 81 | + .send() |
| 82 | + .await |
| 83 | + .map_err(log_reqwest_error)?; |
| 84 | + tracing::log::trace!("Returning response from outbound request to {}", req.uri); |
| 85 | + response_from_reqwest(resp).await |
| 86 | + } |
| 87 | + .await) |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +fn log_reqwest_error(err: reqwest::Error) -> HttpError { |
| 92 | + let error_desc = if err.is_timeout() { |
| 93 | + "timeout error" |
| 94 | + } else if err.is_connect() { |
| 95 | + "connection error" |
| 96 | + } else if err.is_body() || err.is_decode() { |
| 97 | + "message body error" |
| 98 | + } else if err.is_request() { |
| 99 | + "request error" |
| 100 | + } else { |
| 101 | + "error" |
| 102 | + }; |
| 103 | + tracing::warn!( |
| 104 | + "Outbound HTTP {}: URL {}, error detail {:?}", |
| 105 | + error_desc, |
| 106 | + err.url() |
| 107 | + .map(|u| u.to_string()) |
| 108 | + .unwrap_or_else(|| "<unknown>".to_owned()), |
| 109 | + err |
| 110 | + ); |
| 111 | + HttpError::RuntimeError |
| 112 | +} |
| 113 | + |
| 114 | +fn method_from(m: Method) -> http::Method { |
| 115 | + match m { |
| 116 | + Method::Get => http::Method::GET, |
| 117 | + Method::Post => http::Method::POST, |
| 118 | + Method::Put => http::Method::PUT, |
| 119 | + Method::Delete => http::Method::DELETE, |
| 120 | + Method::Patch => http::Method::PATCH, |
| 121 | + Method::Head => http::Method::HEAD, |
| 122 | + Method::Options => http::Method::OPTIONS, |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +async fn response_from_reqwest(res: reqwest::Response) -> Result<Response, HttpError> { |
| 127 | + let status = res.status().as_u16(); |
| 128 | + let headers = response_headers(res.headers()).map_err(|_| HttpError::RuntimeError)?; |
| 129 | + |
| 130 | + let body = Some( |
| 131 | + res.bytes() |
| 132 | + .await |
| 133 | + .map_err(|_| HttpError::RuntimeError)? |
| 134 | + .to_vec(), |
| 135 | + ); |
| 136 | + |
| 137 | + Ok(Response { |
| 138 | + status, |
| 139 | + headers, |
| 140 | + body, |
| 141 | + }) |
| 142 | +} |
| 143 | + |
| 144 | +fn request_headers(h: Headers) -> anyhow::Result<HeaderMap> { |
| 145 | + let mut res = HeaderMap::new(); |
| 146 | + for (k, v) in h { |
| 147 | + res.insert( |
| 148 | + http::header::HeaderName::try_from(k)?, |
| 149 | + http::header::HeaderValue::try_from(v)?, |
| 150 | + ); |
| 151 | + } |
| 152 | + Ok(res) |
| 153 | +} |
| 154 | + |
| 155 | +fn response_headers(h: &HeaderMap) -> anyhow::Result<Option<Vec<(String, String)>>> { |
| 156 | + let mut res: Vec<(String, String)> = vec![]; |
| 157 | + |
| 158 | + for (k, v) in h { |
| 159 | + res.push(( |
| 160 | + k.to_string(), |
| 161 | + std::str::from_utf8(v.as_bytes())?.to_string(), |
| 162 | + )); |
| 163 | + } |
| 164 | + |
| 165 | + Ok(Some(res)) |
| 166 | +} |
| 167 | + |
| 168 | +fn host(url: &str) -> Option<String> { |
| 169 | + url::Url::parse(url) |
| 170 | + .ok() |
| 171 | + .and_then(|u| u.host().map(|h| h.to_string())) |
| 172 | +} |
0 commit comments