use std::cell::RefCell;
use std::rc::Rc;
use crossbeam_channel::{bounded, Sender};
use deno_core::{extension, op2, ExtensionFileSource, OpState};
#[allow(dead_code)]
pub(crate) enum Message {
Host {
kind: String,
payload: String,
answer: Sender<String>,
},
Done(Result<String, String>),
}
pub(crate) type Current = Rc<RefCell<Option<Sender<Message>>>>;
#[op2]
#[string]
fn op_apiplant_host(state: &mut OpState, #[string] kind: &str, #[string] payload: &str) -> String {
let current = state.borrow::<Current>().clone();
let replies = current.borrow().clone();
let Some(replies) = replies else {
return r#"{"error":"no invocation in progress"}"#.to_string();
};
let (answer, wait) = bounded(1);
let sent = replies.send(Message::Host {
kind: kind.to_string(),
payload: payload.to_string(),
answer,
});
if sent.is_err() {
return r#"{"error":"the host stopped listening"}"#.to_string();
}
wait.recv()
.unwrap_or_else(|_| r#"{"error":"the host stopped listening"}"#.to_string())
}
#[derive(serde::Deserialize)]
pub(crate) struct FetchRequest {
method: String,
url: String,
headers: Vec<(String, String)>,
redirect: String,
}
#[derive(serde::Serialize)]
pub(crate) struct FetchResponse {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
url: String,
redirected: bool,
body: deno_core::ToJsBuffer,
}
fn fetch_timeout() -> std::time::Duration {
let ms = std::env::var("APIPLANT_FETCH_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30_000);
std::time::Duration::from_millis(ms)
}
fn egress_allowed(url: &reqwest::Url) -> bool {
match std::env::var("APIPLANT_FETCH_ALLOW") {
Ok(rule) => host_matches(url.host_str(), &rule),
Err(_) => true,
}
}
fn host_matches(host: Option<&str>, rule: &str) -> bool {
let Some(host) = host else {
return false;
};
let host = host.to_ascii_lowercase();
rule.split(',')
.map(|pattern| pattern.trim().to_ascii_lowercase())
.filter(|pattern| !pattern.is_empty())
.any(|pattern| match pattern.strip_prefix("*.") {
Some(domain) => host == domain || host.ends_with(&format!(".{domain}")),
None => host == pattern,
})
}
fn client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("a reqwest client with no TLS backend configured")
})
}
#[op2(async(deferred))]
#[serde]
async fn op_apiplant_fetch(
#[serde] request: FetchRequest,
#[buffer(copy)] body: Option<Vec<u8>>,
) -> Result<FetchResponse, deno_error::JsErrorBox> {
let fail = |message: String| deno_error::JsErrorBox::type_error(message);
let mut url = reqwest::Url::parse(&request.url)
.map_err(|e| fail(format!("cannot fetch `{}`: {e}", request.url)))?;
let method = reqwest::Method::from_bytes(request.method.as_bytes())
.map_err(|_| fail(format!("`{}` is not a valid HTTP method", request.method)))?;
let follow = request.redirect == "follow";
let mut redirected = false;
for _ in 0..20 {
if !matches!(url.scheme(), "http" | "https") {
return Err(fail(format!(
"cannot fetch `{url}`: only http and https are supported"
)));
}
if !egress_allowed(&url) {
return Err(fail(format!(
"cannot fetch `{url}`: the host is not in APIPLANT_FETCH_ALLOW"
)));
}
let mut outgoing = client()
.request(method.clone(), url.clone())
.timeout(fetch_timeout());
for (name, value) in &request.headers {
outgoing = outgoing.header(name, value);
}
if let Some(body) = body.clone() {
outgoing = outgoing.body(body);
}
let response = outgoing.send().await.map_err(|e| {
let mut reason = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
reason = format!("{reason}: {cause}");
source = cause.source();
}
fail(format!("cannot fetch `{url}`: {reason}"))
})?;
let status = response.status();
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if follow {
if let (true, Some(location)) = (status.is_redirection(), location) {
url = url
.join(&location)
.map_err(|e| fail(format!("cannot follow a redirect to `{location}`: {e}")))?;
redirected = true;
continue;
}
}
let final_url = response.url().to_string();
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
value.to_str().unwrap_or_default().to_string(),
)
})
.collect();
let bytes = response
.bytes()
.await
.map_err(|e| fail(format!("cannot read the response body: {e}")))?;
return Ok(FetchResponse {
status: status.as_u16(),
status_text: status.canonical_reason().unwrap_or_default().to_string(),
headers,
url: final_url,
redirected,
body: bytes.to_vec().into(),
});
}
Err(fail(format!(
"too many redirects fetching `{}`",
request.url
)))
}
pub(crate) const BOOTSTRAP: &str = "ext:apiplant_js/bootstrap.js";
pub(crate) const FETCH: &str = "ext:apiplant_js/fetch.js";
const FETCH_SOURCE: &str = include_str!("../assets/fetch.js");
pub(crate) const BOOTSTRAP_SOURCE: &str = include_str!("../assets/bootstrap.js");
extension!(
apiplant_js,
deps = [deno_webidl, deno_web],
ops = [op_apiplant_host, op_apiplant_fetch],
esm_entry_point = BOOTSTRAP,
options = { current: Current },
state = |state, options| state.put::<Current>(options.current),
);
pub(crate) fn extension(current: Current) -> deno_core::Extension {
let mut extension = apiplant_js::init(current);
extension.esm_files = std::borrow::Cow::Owned(vec![
ExtensionFileSource::new_computed(FETCH, FETCH_SOURCE.into()),
ExtensionFileSource::new_computed(BOOTSTRAP, BOOTSTRAP_SOURCE.into()),
]);
extension
}
pub(crate) fn detached() -> Current {
Rc::new(RefCell::new(None))
}
#[cfg(test)]
mod tests {
use super::host_matches;
#[test]
fn the_egress_allowlist_matches_hosts_not_substrings() {
assert!(host_matches(Some("api.stripe.com"), "api.stripe.com"));
assert!(host_matches(
Some("api.stripe.com"),
"example.com, api.stripe.com"
));
assert!(!host_matches(Some("api.stripe.com"), "stripe.com"));
assert!(host_matches(Some("stripe.com"), "*.stripe.com"));
assert!(host_matches(Some("api.stripe.com"), "*.stripe.com"));
assert!(host_matches(Some("a.b.stripe.com"), "*.stripe.com"));
assert!(!host_matches(Some("evilstripe.com"), "*.stripe.com"));
assert!(!host_matches(Some("stripe.com.evil.net"), "*.stripe.com"));
assert!(host_matches(Some("API.Stripe.com"), " *.STRIPE.com , "));
assert!(!host_matches(Some("api.stripe.com"), ""));
assert!(!host_matches(None, "*.stripe.com"));
}
}