use std::sync::Arc;
use super::{CallResult, CallValue, CapabilityCall, CapabilityRuntime, DenialReason};
pub const MAX_OUTBOUND_HEADERS: usize = 16;
pub const MAX_RESPONSE_HEADER_BYTES: usize = 8 * 1024;
pub const ALLOWED_OUTBOUND_REQUEST_HEADERS: &[&str] = &[
"accept",
"accept-language",
"content-type",
"idempotency-key",
"if-match",
"if-none-match",
"user-agent",
];
pub const ALLOWED_OUTBOUND_RESPONSE_HEADERS: &[&str] = &[
"content-type",
"etag",
"last-modified",
"location",
"retry-after",
];
const ALLOWED_OUTBOUND_METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct OutboundRequest {
pub plugin: String,
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: String,
pub max_response_bytes: usize,
pub max_response_headers: usize,
pub max_response_header_bytes: usize,
pub allowed_hosts: Vec<String>,
pub follow_redirects: bool,
pub timeout: std::time::Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboundResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
pub final_url: String,
}
impl OutboundResponse {
#[must_use]
pub fn from_url(url: impl Into<String>, status: u16, body: impl Into<String>) -> Self {
Self {
status,
headers: Vec::new(),
body: body.into(),
final_url: url.into(),
}
}
}
pub trait OutboundHttp: Send + Sync + 'static {
fn fetch(&self, request: OutboundRequest) -> Result<OutboundResponse, String>;
}
#[must_use]
pub fn host_of(url: &str) -> Option<String> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let authority = rest
.split(['/', '?', '#'])
.next()
.filter(|authority| !authority.is_empty())?;
if authority.contains('@') {
return None;
}
if authority.contains('[') || authority.contains(']') {
return None;
}
let mut parts = authority.split(':');
let host = parts.next()?;
if let Some(port) = parts.next() {
if parts.next().is_some() || port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
}
if host.len() > super::super::grants::MAX_HOST_LEN {
return None;
}
let host = host.to_ascii_lowercase();
super::super::grants::is_grantable_host(&host).then_some(host)
}
#[must_use]
pub fn attempted_authority(url: &str) -> String {
let rest = url
.split_once("://")
.map_or(url, |(_, rest)| rest)
.trim_start_matches('/');
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let authority = authority.rsplit('@').next().unwrap_or(authority);
if authority.is_empty() {
url.to_owned()
} else {
authority.to_ascii_lowercase()
}
}
pub(super) fn check_request(
runtime: &CapabilityRuntime,
call: &CapabilityCall,
) -> Result<&'static str, CallResult> {
let id = call.id();
let CapabilityCall::HttpFetch {
method,
url,
headers,
body,
..
} = call
else {
return Err(CallResult::denied(
id,
DenialReason::Malformed,
"not an outbound call",
));
};
let Some(&method) = ALLOWED_OUTBOUND_METHODS
.iter()
.find(|allowed| allowed.eq_ignore_ascii_case(method))
else {
return Err(CallResult::denied(
id,
DenialReason::Malformed,
format!(
"method {method} is not one of {allowed}",
method = super::super::manifest::rejected(method),
allowed = ALLOWED_OUTBOUND_METHODS.join(", ")
),
));
};
if headers.len() > MAX_OUTBOUND_HEADERS {
return Err(CallResult::denied(
id,
DenialReason::Malformed,
format!("at most {MAX_OUTBOUND_HEADERS} outbound request headers"),
));
}
for (name, value) in headers {
if !ALLOWED_OUTBOUND_REQUEST_HEADERS
.iter()
.any(|known| known.eq_ignore_ascii_case(name))
{
return Err(CallResult::denied(
id,
DenialReason::NotInGrant,
format!(
"outbound request header {name:?} is not one a sandboxed plugin may set",
name = super::super::manifest::rejected(name)
),
));
}
if value.bytes().any(|byte| byte == b'\r' || byte == b'\n') {
return Err(CallResult::denied(
id,
DenialReason::Malformed,
"an outbound header value may not carry CR or LF",
));
}
}
let request_ceiling = runtime.quotas().outbound_request_bytes as usize;
let header_weight: usize = headers
.iter()
.map(|(name, value)| name.len().saturating_add(value.len()))
.fold(0, usize::saturating_add);
let request_weight = header_weight
.saturating_add(body.len())
.saturating_add(url.len());
if request_weight > request_ceiling {
return Err(CallResult::denied(
id,
DenialReason::QuotaExceeded,
format!(
"the request is {request_weight} bytes of url, headers and body, over this \
plugin's {request_ceiling}-byte `outbound_request_bytes` quota"
),
));
}
Ok(method)
}
#[allow(
clippy::too_many_lines,
reason = "the request is assembled, sent and re-checked in one pass; splitting it would put \
the redirect re-check somewhere the assembly could be changed without it"
)]
pub(super) fn perform(
runtime: &CapabilityRuntime,
call: &CapabilityCall,
host: &str,
) -> CallResult {
let id = call.id();
let CapabilityCall::HttpFetch {
url, headers, body, ..
} = call
else {
return CallResult::denied(id, DenialReason::Malformed, "not an outbound call");
};
let Some(client) = runtime.services.http.clone() else {
return CallResult::denied(
id,
DenialReason::Unavailable,
"this host has no outbound HTTP backend wired for sandboxed plugins",
);
};
let method = match check_request(runtime, call) {
Ok(method) => method.to_owned(),
Err(result) => return result,
};
let allowed: Vec<(String, String)> = headers
.iter()
.map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
.collect();
let ceiling = runtime.quotas().outbound_response_bytes as usize;
let request = OutboundRequest {
plugin: runtime.plugin.clone(),
method,
url: url.clone(),
headers: allowed,
body: body.clone(),
max_response_bytes: ceiling,
max_response_headers: MAX_OUTBOUND_HEADERS,
max_response_header_bytes: MAX_RESPONSE_HEADER_BYTES,
allowed_hosts: runtime
.grants
.list_for(super::super::manifest::SandboxCapability::HttpOutbound)
.unwrap_or_default()
.to_vec(),
follow_redirects: false,
timeout: std::time::Duration::from_millis(u64::from(runtime.quotas().outbound_timeout_ms)),
};
match client.fetch(request) {
Ok(response) => {
let landed = host_of(&response.final_url);
if landed.as_deref() != Some(host) {
return CallResult::denied(
id,
DenialReason::NotInGrant,
format!(
"the call to {host} returned bytes from {landed}, which \
`[grants].hosts` does not name; a redirect is not followed on a \
plugin's behalf",
landed = super::super::manifest::rejected(
landed.as_deref().unwrap_or(&response.final_url)
)
),
);
}
let encoded = super::encoded_text_len(&response.body);
if encoded > super::MAX_RESULT_BYTES {
return CallResult::denied(
id,
DenialReason::ResponseTooLarge,
format!(
"{host} answered {found} bytes that encode to {encoded}, over the \
{max}-byte ceiling one reply may carry",
found = response.body.len(),
max = super::MAX_RESULT_BYTES
),
);
}
if response.body.len() > ceiling {
return CallResult::denied(
id,
DenialReason::ResponseTooLarge,
format!(
"{host} answered {found} bytes, over the {ceiling}-byte \
`outbound_response_bytes` quota",
found = response.body.len()
),
);
}
let mut headers = Vec::new();
let mut header_bytes = 0_usize;
for (name, value) in response.headers {
if !ALLOWED_OUTBOUND_RESPONSE_HEADERS
.iter()
.any(|known| known.eq_ignore_ascii_case(&name))
{
continue;
}
if headers.len() >= MAX_OUTBOUND_HEADERS {
break;
}
let weight = name.len().saturating_add(value.len());
if header_bytes.saturating_add(weight) > MAX_RESPONSE_HEADER_BYTES {
break;
}
header_bytes = header_bytes.saturating_add(weight);
headers.push((name, value));
}
CallResult::Ok {
id,
value: CallValue::Http {
status: response.status,
headers,
body: response.body,
},
}
}
Err(detail) => CallResult::denied(id, DenialReason::BackendError, detail),
}
}
#[derive(Debug, Default)]
pub struct RecordingHttp {
answers: std::sync::Mutex<Vec<(String, OutboundResponse)>>,
seen: std::sync::Mutex<Vec<OutboundRequest>>,
}
impl RecordingHttp {
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn answer(&self, url: impl Into<String>, response: OutboundResponse) {
self.answers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((url.into(), response));
}
#[must_use]
pub fn seen(&self) -> Vec<OutboundRequest> {
self.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl RecordingHttp {
pub fn answer_from(&self, url: impl Into<String>, elsewhere: impl Into<String>) {
self.answers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((
url.into(),
OutboundResponse {
status: 200,
headers: Vec::new(),
body: "redirected".to_owned(),
final_url: elsewhere.into(),
},
));
}
}
impl OutboundHttp for RecordingHttp {
fn fetch(&self, request: OutboundRequest) -> Result<OutboundResponse, String> {
let answer = self
.answers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.find(|(url, _)| *url == request.url)
.map(|(_, response)| response.clone());
self.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request);
answer.ok_or_else(|| "no upstream answered".to_owned())
}
}