#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::fmt;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde::{Deserialize, Serialize};
use super::manifest::{SandboxCapability, WIRE_VERSION};
use crate::security::path::{dot_segment_len, percent_escapes};
pub const ALLOWED_REQUEST_HEADERS: &[&str] = &[
"accept",
"accept-charset",
"accept-encoding",
"accept-language",
"cache-control",
"content-length",
"content-type",
"if-match",
"if-modified-since",
"if-none-match",
"if-range",
"if-unmodified-since",
"range",
"user-agent",
];
#[must_use]
pub fn request_header_allowed(name: &str) -> bool {
ALLOWED_REQUEST_HEADERS
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
}
pub const ALLOWED_RESPONSE_HEADERS: &[&str] = &[
"accept-ranges",
"age",
"cache-control",
"content-disposition",
"content-language",
"content-range",
"content-type",
"etag",
"expires",
"last-modified",
"location",
"retry-after",
"vary",
];
fn redirect_target_allowed(
target: &str,
prefix: &str,
owned: &OwnedRoutes,
requested: &str,
status: u16,
) -> bool {
if target.contains('\\') {
return false;
}
if target.chars().any(|ch| ch.is_ascii_control() || ch == ' ') {
return false;
}
let Some(rest) = target.strip_prefix(prefix) else {
return false;
};
if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#'))
{
return false;
}
let path = rest.split(['?', '#']).next().unwrap_or_default();
if path
.split('/')
.any(|segment| dot_segment_len(segment).is_some())
{
return false;
}
if percent_escapes(path).any(|(byte, _)| matches!(byte, b'/' | b'\\')) {
return false;
}
let full = target.split(['?', '#']).next().unwrap_or_default();
redirect_lands_on_the_plugin(status, requested, full, owned)
}
pub struct OwnedRoutes(Vec<(String, String)>);
impl OwnedRoutes {
#[must_use]
pub fn from_routes<'a>(routes: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
Self(
routes
.into_iter()
.map(|(method, path)| (method.to_ascii_uppercase(), path.to_owned()))
.collect(),
)
}
#[must_use]
fn owns(&self, method: &str, path: &str) -> bool {
self.0.iter().any(|(declared_method, declared_path)| {
declared_path == path
&& (declared_method.eq_ignore_ascii_case(method)
|| (method.eq_ignore_ascii_case("HEAD") && declared_method == "GET"))
})
}
}
fn redirect_lands_on_the_plugin(
status: u16,
requested: &str,
path: &str,
owned: &OwnedRoutes,
) -> bool {
match status {
307 | 308 => owned.owns(requested, path),
303 => owned.owns("GET", path),
300..=399 => owned.owns(requested, path) && owned.owns("GET", path),
_ => true,
}
}
pub const ALLOWED_RESPONSE_CONTENT_TYPES: &[&str] = &[
"text/plain",
"text/csv",
"application/json",
"application/octet-stream",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
];
#[must_use]
pub fn response_header_allowed(name: &str) -> bool {
ALLOWED_RESPONSE_HEADERS
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
}
#[must_use]
fn content_type_allowed(essence: &str) -> bool {
ALLOWED_RESPONSE_CONTENT_TYPES
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(essence))
}
const DENIED_NAME_EXCERPT: usize = 128;
fn denied_excerpt(name: &str) -> String {
let mut out = String::with_capacity(name.len().min(DENIED_NAME_EXCERPT));
for (kept, ch) in name.chars().enumerate() {
if kept == DENIED_NAME_EXCERPT {
out.push_str(super::host::TRUNCATION_MARKER);
break;
}
out.push(ch.to_ascii_lowercase());
}
out
}
#[derive(Debug)]
#[non_exhaustive]
pub enum WireError {
Json(serde_json::Error),
InvalidStatus(u16),
InvalidHeader {
name: String,
reason: &'static str,
},
UnsupportedContentType(String),
ResponseTooLarge {
found: usize,
max: usize,
},
}
impl fmt::Display for WireError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Json(err) => write!(f, "malformed sandbox wire frame: {err}"),
Self::InvalidStatus(status) => {
write!(
f,
"the plugin answered with status {status}, which is not a valid HTTP status"
)
}
Self::InvalidHeader { name, reason } => {
write!(
f,
"the plugin answered with an invalid header {name:?}: {reason}"
)
}
Self::UnsupportedContentType(essence) => write!(
f,
"the plugin answered with content type `{essence}`, which a sandboxed plugin may \
not serve: a document or a script from the host's own origin would carry the \
host's authority. Allowed: {allowed}",
allowed = ALLOWED_RESPONSE_CONTENT_TYPES.join(", ")
),
Self::ResponseTooLarge { found, max } => write!(
f,
"the plugin answered with {found} bytes, over its declared {max}-byte ceiling"
),
}
}
}
impl std::error::Error for WireError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Json(err) => Some(err),
_ => None,
}
}
}
impl From<serde_json::Error> for WireError {
fn from(err: serde_json::Error) -> Self {
Self::Json(err)
}
}
pub(crate) fn to_line<T: Serialize>(frame: &T) -> Result<String, WireError> {
let mut line = serde_json::to_string(frame)?;
line.push('\n');
Ok(line)
}
pub(crate) fn to_exact_line<T: Serialize>(frame: &T) -> Result<String, WireError> {
struct Count(usize);
impl std::io::Write for Count {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buf.len());
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut count = Count(0);
serde_json::to_writer(&mut count, frame)?;
let mut line = Vec::with_capacity(count.0.saturating_add(1));
serde_json::to_writer(&mut line, frame)?;
line.push(b'\n');
String::from_utf8(line).map_err(|err| WireError::Json(serde::ser::Error::custom(err)))
}
pub(crate) fn from_line<T: serde::de::DeserializeOwned>(line: &str) -> Result<T, WireError> {
Ok(serde_json::from_str(line)?)
}
pub(crate) const MAX_FRAME_VALUES: usize = 65_536;
pub(crate) const MAX_FRAME_DEPTH: usize = 32;
fn check_frame_shape(line: &str) -> Result<(), WireError> {
let (mut depth, mut values) = (0_usize, 0_usize);
let (mut in_string, mut escaped) = (false, false);
for byte in line.bytes() {
if in_string {
match (escaped, byte) {
(true, _) => escaped = false,
(false, b'\\') => escaped = true,
(false, b'"') => in_string = false,
_ => {}
}
continue;
}
match byte {
b'"' => in_string = true,
b'{' | b'[' => {
depth = depth.saturating_add(1);
if depth > MAX_FRAME_DEPTH {
return Err(shape_error(format!(
"a frame nests deeper than {MAX_FRAME_DEPTH} levels"
)));
}
values = values.saturating_add(1);
}
b'}' | b']' => {
depth = depth
.checked_sub(1)
.ok_or_else(|| shape_error("a frame closes more than it opens".to_owned()))?;
}
b',' => values = values.saturating_add(1),
_ => continue,
}
if values > MAX_FRAME_VALUES {
return Err(shape_error(format!(
"a frame holds more than {MAX_FRAME_VALUES} values"
)));
}
}
Ok(())
}
fn shape_error(message: String) -> WireError {
WireError::Json(serde::de::Error::custom(message))
}
pub(crate) fn from_guest_line(line: &str) -> Result<GuestFrame, WireError> {
check_frame_shape(line)?;
from_line(line)
}
mod body_b64 {
use super::BASE64;
use base64::Engine as _;
use serde::{Deserialize as _, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
ser.collect_str(&base64::display::Base64Display::new(bytes, &BASE64))
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
let raw = std::borrow::Cow::<'de, str>::deserialize(de)?;
BASE64
.decode(raw.as_ref())
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxRequest {
pub method: String,
pub route: String,
pub path: String,
pub query: String,
pub path_params: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
pub const MAX_RESPONSE_HEADERS: usize = 64;
mod bounded_headers {
use serde::de::{Deserializer, Error as _, SeqAccess, Visitor};
use std::fmt;
pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<(String, String)>, D::Error>
where
D: Deserializer<'de>,
{
struct Bounded;
impl<'de> Visitor<'de> for Bounded {
type Value = Vec<(String, String)>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"at most {} response header pairs",
super::MAX_RESPONSE_HEADERS
)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut headers = Vec::new();
while let Some(pair) = seq.next_element::<(String, String)>()? {
if headers.len() >= super::MAX_RESPONSE_HEADERS {
return Err(A::Error::custom(format!(
"a response may carry at most {} headers",
super::MAX_RESPONSE_HEADERS
)));
}
headers.push(pair);
}
Ok(headers)
}
}
deserializer.deserialize_seq(Bounded)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxResponse {
pub status: u16,
#[serde(deserialize_with = "bounded_headers::deserialize")]
pub headers: Vec<(String, String)>,
#[serde(rename = "body_b64", with = "body_b64")]
pub body: Vec<u8>,
}
impl SandboxResponse {
#[must_use]
pub fn sanitize(
mut self,
prefix: &str,
owned: &OwnedRoutes,
requested: &str,
) -> (Self, Vec<String>) {
let mut denied = Vec::new();
let status = self.status;
self.headers.retain(|(name, value)| {
if !response_header_allowed(name) {
denied.push(denied_excerpt(name));
return false;
}
if name.eq_ignore_ascii_case("location")
&& !redirect_target_allowed(value, prefix, owned, requested, status)
{
denied.push(denied_excerpt(name));
return false;
}
true
});
(self, denied)
}
pub fn validate(&self) -> Result<(), WireError> {
if !(200..=599).contains(&self.status) {
return Err(WireError::InvalidStatus(self.status));
}
for (name, value) in &self.headers {
if http::HeaderName::try_from(name.as_str()).is_err() {
return Err(WireError::InvalidHeader {
name: name.clone(),
reason: "not a legal HTTP header name",
});
}
if http::HeaderValue::try_from(value.as_str()).is_err() {
return Err(WireError::InvalidHeader {
name: name.clone(),
reason: "not a legal HTTP header value (control characters are refused, \
because a `\\r\\n` here would be response splitting)",
});
}
}
Ok(())
}
#[must_use]
pub fn refused_content_type(&self) -> Option<String> {
let mut declared = self
.headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value.split(';').next().unwrap_or_default().trim());
let first = declared.next()?;
if let Some(extra) = declared.next() {
return Some(if content_type_allowed(extra) {
denied_excerpt(first)
} else {
denied_excerpt(extra)
});
}
if content_type_allowed(first) {
None
} else {
Some(denied_excerpt(first))
}
}
pub fn check_size(&self, max: usize) -> Result<(), WireError> {
let header_bytes: usize = self
.headers
.iter()
.map(|(name, value)| name.len().saturating_add(value.len()).saturating_add(4))
.sum();
let found = self.body.len().saturating_add(header_bytes);
if found > max {
return Err(WireError::ResponseTooLarge { found, max });
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub(crate) enum HostFrame {
Request {
wire_version: u32,
granted: Vec<SandboxCapability>,
method: String,
route: String,
path: String,
query: String,
path_params: Vec<(String, String)>,
headers: Vec<(String, String)>,
#[serde(rename = "body_b64", with = "body_b64")]
body: Vec<u8>,
},
Render {
wire_version: u32,
granted: Vec<SandboxCapability>,
slot: String,
context: Vec<(String, String)>,
},
CallResult(super::capability::CallResult),
}
impl HostFrame {
#[must_use]
pub(crate) fn render(
slot: &str,
context: &[(String, String)],
granted: &[SandboxCapability],
) -> Self {
Self::Render {
wire_version: WIRE_VERSION,
granted: granted.to_vec(),
slot: slot.to_owned(),
context: context.to_vec(),
}
}
#[must_use]
pub fn request(request: &SandboxRequest, granted: &[SandboxCapability]) -> Self {
Self::Request {
wire_version: WIRE_VERSION,
granted: granted.to_vec(),
method: request.method.clone(),
route: request.route.clone(),
path: request.path.clone(),
query: request.query.clone(),
path_params: request.path_params.clone(),
headers: canonicalize_headers(&request.headers),
body: request.body.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub(crate) enum GuestFrame {
Response(SandboxResponse),
Error {
detail: String,
},
Call(super::capability::CapabilityCall),
Fragment {
#[serde(
deserialize_with = "super::capability::bounded_tree::<_, { super::capability::render::MAX_NODES }>"
)]
nodes: Vec<super::capability::FragmentNode>,
},
}
#[must_use]
pub(crate) fn canonicalize_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = headers
.iter()
.filter(|(name, _)| request_header_allowed(name))
.map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
.collect();
out.sort_by(|(left, _), (right, _)| left.cmp(right));
out
}
#[cfg(test)]
mod tests {
fn any_route_under_hello() -> OwnedRoutes {
OwnedRoutes::from_routes([
("GET", "/hello"),
("GET", "/hello/.."),
("GET", "/hello/.. "),
("GET", "/hello/a b"),
("GET", "/hello/a%20b"),
("GET", "/hello/greet"),
])
}
fn hello_routes() -> OwnedRoutes {
OwnedRoutes::from_routes([
("GET", "/hello"),
("GET", "/hello/greet"),
("GET", "/hello/items/{id}"),
])
}
#[test]
fn a_redirect_must_land_on_the_plugin_under_the_method_it_replays() {
let owned = OwnedRoutes::from_routes([("GET", "/hello"), ("GET", "/hello/transfer")]);
let preserved = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), "/hello/transfer".to_owned())],
body: Vec::new(),
};
let (clean, denied) = preserved.sanitize("/hello", &owned, "POST");
assert!(
clean.headers.is_empty(),
"a 307 replaying POST at a path the plugin serves only for GET must be refused",
);
assert_eq!(denied, vec!["location".to_owned()], "and recorded");
let same_method = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), "/hello/transfer".to_owned())],
body: Vec::new(),
};
let (clean, denied) = same_method.sanitize("/hello", &owned, "GET");
assert_eq!(
clean.headers.len(),
1,
"a 307 replaying GET lands on the plugin"
);
assert!(denied.is_empty());
let rewritten = SandboxResponse {
status: 303,
headers: vec![("location".to_owned(), "/hello/transfer".to_owned())],
body: Vec::new(),
};
let (clean, denied) = rewritten.sanitize("/hello", &owned, "POST");
assert_eq!(
clean.headers.len(),
1,
"a 303 arrives as GET, which the plugin serves"
);
assert!(denied.is_empty());
let ambiguous = SandboxResponse {
status: 302,
headers: vec![("location".to_owned(), "/hello/transfer".to_owned())],
body: Vec::new(),
};
let (clean, _) = ambiguous.sanitize("/hello", &owned, "POST");
assert!(
clean.headers.is_empty(),
"a 302 may be replayed as either method, so a POST it might preserve must be owned",
);
let head = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), "/hello/transfer".to_owned())],
body: Vec::new(),
};
let (clean, _) = head.sanitize("/hello", &owned, "HEAD");
assert_eq!(
clean.headers.len(),
1,
"a GET route serves the HEAD the router implies"
);
}
#[test]
fn a_dynamic_declaration_does_not_own_the_literals_it_would_match() {
let shadowed = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), "/hello/items/7".to_owned())],
body: Vec::new(),
};
let (clean, denied) = shadowed.sanitize("/hello", &hello_routes(), "GET");
assert!(
clean.headers.is_empty(),
"a target reached only through the plugin's own template must be \
refused: the application may hold the literal, and routing prefers it",
);
assert_eq!(denied, vec!["location".to_owned()], "and recorded");
let declared = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), "/hello/greet".to_owned())],
body: Vec::new(),
};
let (clean, denied) = declared.sanitize("/hello", &hello_routes(), "GET");
assert_eq!(clean.headers.len(), 1, "a declared literal must still pass");
assert!(denied.is_empty());
}
#[test]
fn a_redirect_may_only_name_a_route_the_plugin_serves() {
let application_owned = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), "/hello/other".to_owned())],
body: Vec::new(),
};
let (clean, denied) = application_owned.sanitize("/hello", &hello_routes(), "GET");
assert!(
clean.headers.is_empty(),
"a redirect to a route the plugin does not serve must be stripped",
);
assert_eq!(denied, vec!["location".to_owned()], "and recorded");
for target in ["/hello", "/hello/greet", "/hello/greet?x=1"] {
let own = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), target.to_owned())],
body: Vec::new(),
};
let (clean, denied) = own.sanitize("/hello", &hello_routes(), "GET");
assert_eq!(
clean.headers.len(),
1,
"the plugin's own route {target} must remain a legal redirect",
);
assert!(denied.is_empty(), "{target} was denied");
}
for target in ["/admin", "/hello/../admin", "/hello/%2e%2e/admin"] {
let escaping = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), target.to_owned())],
body: Vec::new(),
};
let (clean, _) = escaping.sanitize("/hello", &hello_routes(), "GET");
assert!(clean.headers.is_empty(), "{target} must still be refused");
}
}
#[test]
fn a_denied_header_name_is_not_copied_whole_to_be_thrown_away() {
let huge = format!("set-cookie{}", "x".repeat(64 * 1024));
let response = SandboxResponse {
status: 200,
headers: vec![(huge.clone(), "v".to_owned())],
body: Vec::new(),
};
let (clean, denied) = response.sanitize("/hello", &hello_routes(), "GET");
assert!(
clean.headers.is_empty(),
"the header must still be stripped"
);
assert_eq!(denied.len(), 1, "the strip must still be recorded");
assert!(
denied[0].len() < huge.len(),
"the whole name was carried into the denial: {} bytes",
denied[0].len(),
);
assert!(
denied[0].starts_with("set-cookie"),
"the excerpt must still name the header: {:?}",
denied[0].get(..40),
);
let shouted = SandboxResponse {
status: 200,
headers: vec![("SET-COOKIE".to_owned(), "v".to_owned())],
body: Vec::new(),
};
let (clean, denied) = shouted.sanitize("/hello", &hello_routes(), "GET");
assert!(
clean.headers.is_empty(),
"a shouted name must still be refused"
);
assert_eq!(
denied,
vec!["set-cookie".to_owned()],
"recorded lower-cased"
);
let fine = SandboxResponse {
status: 200,
headers: vec![("Content-Type".to_owned(), "text/plain".to_owned())],
body: Vec::new(),
};
let (clean, denied) = fine.sanitize("/hello", &hello_routes(), "GET");
assert_eq!(clean.headers.len(), 1, "an allowed header must survive");
assert!(denied.is_empty(), "nothing was denied");
}
#[test]
fn a_redirect_cannot_hide_its_climb_behind_a_space_the_client_trims() {
for smuggled in ["/hello/.. ", "/hello/.. \t", " /hello/.. "] {
let as_the_client_sees_it: String = smuggled
.chars()
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
.collect();
let as_the_client_sees_it = as_the_client_sees_it.trim();
assert_eq!(
as_the_client_sees_it, "/hello/..",
"the fixture must be the same redirect once the client trims it",
);
assert!(
!redirect_target_allowed(smuggled, "/hello", &any_route_under_hello(), "GET", 307),
"{smuggled:?} was accepted, and resolves outside the prefix at the client",
);
}
assert!(!redirect_target_allowed(
"/hello/..",
"/hello",
&any_route_under_hello(),
"GET",
307,
));
assert!(!redirect_target_allowed(
"/hello/a b",
"/hello",
&any_route_under_hello(),
"GET",
307,
));
assert!(redirect_target_allowed(
"/hello/a%20b",
"/hello",
&any_route_under_hello(),
"GET",
307,
));
}
#[test]
fn a_redirect_cannot_hide_its_climb_behind_characters_the_client_deletes() {
let plain = "/hello/../admin";
for smuggled in [
"/hello/\t../admin",
"/hello/\n../admin",
"/hello/\r../admin",
"/hello/.\t./admin",
"/hello\t/../admin",
] {
let as_the_client_sees_it: String = smuggled
.chars()
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
.collect();
assert_eq!(
as_the_client_sees_it, plain,
"the fixture must be the same redirect once the client strips it",
);
assert!(
!redirect_target_allowed(smuggled, "/hello", &any_route_under_hello(), "GET", 307),
"{smuggled:?} was accepted, and resolves to {plain} at the client",
);
}
assert!(!redirect_target_allowed(
plain,
"/hello",
&any_route_under_hello(),
"GET",
307,
));
assert!(redirect_target_allowed(
"/hello/greet?page=2",
"/hello",
&any_route_under_hello(),
"GET",
307,
));
}
#[test]
fn a_redirect_may_not_name_a_segment_the_client_removes() {
for (target, arrives_as) in [
("/hello/./transfer", "/hello/transfer"),
("/hello/%2e/transfer", "/hello/transfer"),
("/hello/%2E/transfer", "/hello/transfer"),
("/hello/transfer/.", "/hello/transfer/"),
("/hello/transfer/%2e", "/hello/transfer/"),
("/hello/%2e%2e%2ftransfer", "/transfer"),
("/hello/a%2F..%2Ftransfer", "/hello/transfer"),
("/hello/%2e%2e%5ctransfer", "/transfer"),
] {
let owned = OwnedRoutes::from_routes([("POST", target)]);
assert!(
!redirect_target_allowed(target, "/hello", &owned, "POST", 307),
"{target} was accepted, and can arrive as {arrives_as}",
);
}
let owned = OwnedRoutes::from_routes([("POST", "/hello/transfer")]);
assert!(redirect_target_allowed(
"/hello/transfer",
"/hello",
&owned,
"POST",
307,
));
let owned = OwnedRoutes::from_routes([("POST", "/hello/%252e")]);
assert!(redirect_target_allowed(
"/hello/%252e",
"/hello",
&owned,
"POST",
307,
));
}
#[test]
fn a_redirect_may_not_leave_the_plugin_s_own_prefix() {
for target in [
"/admin", "https://evil.example/admin", "//evil.example/admin", "/hello/../admin", "/hello/%2e%2e/admin", "/hello/%2E%2E/admin", "/hello/.%2e/admin", "/hello/%2e./admin", "/helloworld", "/hello\\..\\admin", "/hello/\t../admin", "/hello/\n../admin", "/hello/\r../admin", "/hello/.\t./admin", "/hello/.. ", "/hello/.. \t", ] {
let response = SandboxResponse {
status: 307,
headers: vec![("location".to_owned(), target.to_owned())],
body: Vec::new(),
};
let (clean, denied) = response.sanitize("/hello", &hello_routes(), "GET");
assert!(
!clean
.headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("location")),
"{target} survived sanitation"
);
assert!(
denied.iter().any(|name| name == "location"),
"{target} was dropped without being recorded as a denial"
);
}
for target in [
"/hello",
"/hello/greet",
"/hello/greet?page=2",
"/hello#top",
] {
let response = SandboxResponse {
status: 303,
headers: vec![("location".to_owned(), target.to_owned())],
body: Vec::new(),
};
let (clean, _) = response.sanitize("/hello", &hello_routes(), "GET");
assert!(
clean
.headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("location")),
"{target} is inside the prefix and must be allowed"
);
}
}
use super::*;
fn request() -> SandboxRequest {
SandboxRequest {
method: "GET".to_owned(),
route: "/hello/{id}".to_owned(),
path: "/hello/7".to_owned(),
query: "a=b".to_owned(),
path_params: vec![("id".to_owned(), "7".to_owned())],
headers: vec![
("Accept".to_owned(), "text/html".to_owned()),
("Cookie".to_owned(), "session=secret".to_owned()),
("Authorization".to_owned(), "Bearer secret".to_owned()),
("X-Trace".to_owned(), "abc".to_owned()),
],
body: b"hi".to_vec(),
}
}
#[test]
fn a_request_frame_round_trips() {
let line = to_line(&HostFrame::request(
&request(),
&[SandboxCapability::HttpRequest],
))
.expect("serializes");
assert!(line.ends_with('\n'), "frames are newline terminated");
let back: HostFrame = from_line(line.trim_end()).expect("parses");
let HostFrame::Request {
wire_version,
granted,
method,
path,
body,
..
} = back
else {
panic!("a request frame round-trips as a request frame")
};
assert_eq!(wire_version, WIRE_VERSION);
assert_eq!(granted, vec![SandboxCapability::HttpRequest]);
assert_eq!(method, "GET");
assert_eq!(path, "/hello/7");
assert_eq!(body, b"hi");
}
#[test]
fn credentials_never_cross_the_boundary() {
let frame = HostFrame::request(&request(), &[SandboxCapability::HttpRequest]);
let json = to_line(&frame).expect("serializes");
assert!(!json.contains("session=secret"), "{json}");
assert!(!json.contains("Bearer secret"), "{json}");
assert!(!json.contains("x-trace"), "{json}");
let HostFrame::Request { headers, .. } = frame else {
panic!("`HostFrame::request` builds a request frame")
};
assert_eq!(headers, vec![("accept".to_owned(), "text/html".to_owned())]);
}
#[test]
fn request_headers_are_lower_cased_and_sorted_with_stable_duplicates() {
let mut req = request();
req.headers = vec![
("User-Agent".to_owned(), "curl".to_owned()),
("Accept".to_owned(), "first".to_owned()),
("accept".to_owned(), "second".to_owned()),
];
let HostFrame::Request { headers, .. } =
HostFrame::request(&req, &[SandboxCapability::HttpRequest])
else {
panic!("`HostFrame::request` builds a request frame")
};
assert_eq!(
headers,
vec![
("accept".to_owned(), "first".to_owned()),
("accept".to_owned(), "second".to_owned()),
("user-agent".to_owned(), "curl".to_owned()),
]
);
}
#[test]
fn a_response_frame_parses() {
let line = r#"{"op":"response","status":201,"headers":[["content-type","text/plain"]],"body_b64":"aGk="}"#;
let frame: GuestFrame = from_line(line).expect("parses");
let GuestFrame::Response(response) = frame else {
panic!("expected a response frame");
};
assert_eq!(response.status, 201);
assert_eq!(response.body, b"hi");
assert_eq!(
response.headers,
vec![("content-type".to_owned(), "text/plain".to_owned())]
);
}
#[test]
fn an_error_frame_parses() {
let frame: GuestFrame =
from_line(r#"{"op":"error","detail":"no handler"}"#).expect("parses");
assert!(matches!(frame, GuestFrame::Error { .. }), "{frame:?}");
}
fn guest_err(line: &str) -> String {
from_guest_line(line)
.expect_err("the frame must be refused")
.to_string()
}
#[test]
fn a_frame_with_too_many_values_is_refused_before_it_is_parsed() {
let nodes = vec![r#"{"node":"text","text":""}"#; 100_000].join(",");
let message = guest_err(&format!(r#"{{"op":"fragment","nodes":[{nodes}]}}"#));
assert!(message.contains("values"), "{message}");
let columns = (0..100_000)
.map(|i| format!(r#""c{i}":1"#))
.collect::<Vec<_>>()
.join(",");
let message = guest_err(&format!(
r#"{{"op":"call","call":"job-enqueue","id":1,"job_type":"x","payload":{{{columns}}}}}"#
));
assert!(message.contains("values"), "{message}");
let headers = vec![r#"["",""]"#; 100_000].join(",");
let message = guest_err(&format!(
r#"{{"op":"call","call":"http-fetch","id":1,"method":"GET","url":"http://a/","headers":[{headers}]}}"#
));
assert!(message.contains("values"), "{message}");
}
#[test]
fn a_frame_nested_deeper_than_any_legal_frame_is_refused() {
let deep = format!("{}{}", "[".repeat(40), "]".repeat(40));
let message = guest_err(&format!(r#"{{"op":"fragment","nodes":{deep}}}"#));
assert!(message.contains("deep"), "{message}");
}
#[test]
fn the_largest_legal_fragment_still_parses() {
let attributes = vec![r#"["a","b"]"#; 32].join(",");
let node = format!(r#"{{"node":"element","tag":"p","attributes":[{attributes}]}}"#);
let nodes = vec![node; 512].join(",");
let line = format!(r#"{{"op":"fragment","nodes":[{nodes}]}}"#);
assert!(from_guest_line(&line).is_ok());
}
#[test]
fn the_value_ceiling_is_exact() {
let frame = |items: usize| {
let nodes = vec!["0"; items].join(",");
format!(r#"{{"op":"fragment","nodes":[{nodes}]}}"#)
};
let at_limit = MAX_FRAME_VALUES - 2;
assert!(check_frame_shape(&frame(at_limit)).is_ok());
let message = guest_err(&frame(at_limit + 1));
assert!(message.contains("values"), "{message}");
}
#[test]
fn the_depth_ceiling_is_exact() {
let nest = |levels: usize| format!("{}{}", "[".repeat(levels), "]".repeat(levels));
assert!(check_frame_shape(&nest(MAX_FRAME_DEPTH)).is_ok());
let message = guest_err(&nest(MAX_FRAME_DEPTH + 1));
assert!(message.contains("deep"), "{message}");
}
#[test]
fn a_frame_that_closes_more_than_it_opens_is_refused() {
let message = guest_err("]]]]");
assert!(message.contains("closes"), "{message}");
}
#[test]
fn a_backslash_before_the_closing_quote_ends_the_string() {
let deep = "[".repeat(MAX_FRAME_DEPTH + 1);
let line = format!(r#"{{"a":"x\\","b":{deep}}}"#);
assert!(guest_err(&line).contains("deep"));
let line = r#"{"a":"x\"[[[[","b":0}"#;
assert!(check_frame_shape(line).is_ok());
}
#[test]
fn structure_characters_inside_a_string_are_not_counted() {
let text = r#",[{\"\\"#.repeat(50_000);
let line = format!(r#"{{"op":"error","detail":"{text}"}}"#);
assert!(from_guest_line(&line).is_ok());
}
#[test]
fn an_unknown_op_is_refused() {
let err = from_line::<GuestFrame>(r#"{"op":"exec","cmd":"sh"}"#)
.expect_err("an unknown op must be refused");
assert!(matches!(err, WireError::Json(_)), "{err}");
}
#[test]
fn an_unknown_field_on_a_response_frame_is_refused() {
let line = r#"{"op":"response","status":200,"headers":[],"body_b64":"","stream":true}"#;
let err = from_line::<GuestFrame>(line).expect_err("an unknown field must be refused");
assert!(err.to_string().contains("stream"), "{err}");
}
#[test]
fn malformed_json_is_refused() {
assert!(from_line::<GuestFrame>("not json").is_err());
assert!(from_line::<GuestFrame>("").is_err());
}
#[test]
fn a_body_that_is_not_base64_is_refused() {
let line = r#"{"op":"response","status":200,"headers":[],"body_b64":"!!!!"}"#;
assert!(from_line::<GuestFrame>(line).is_err());
}
#[test]
fn a_status_outside_http_range_is_refused() {
for status in [0u16, 99, 100, 101, 199, 600, 1000] {
let response = SandboxResponse {
status,
headers: vec![],
body: vec![],
};
assert!(
response.validate().is_err(),
"status {status} must be refused"
);
}
for status in [200u16, 204, 404, 500, 599] {
let response = SandboxResponse {
status,
headers: vec![],
body: vec![],
};
assert!(
response.validate().is_ok(),
"status {status} must be allowed"
);
}
}
#[test]
fn a_plugin_cannot_mint_a_session_cookie() {
let response = SandboxResponse {
status: 200,
headers: vec![
("Set-Cookie".to_owned(), "session=forged".to_owned()),
("content-type".to_owned(), "text/plain".to_owned()),
],
body: vec![],
};
let (clean, denied) = response.sanitize("/hello", &hello_routes(), "GET");
assert_eq!(denied, vec!["set-cookie".to_owned()]);
assert_eq!(
clean.headers,
vec![("content-type".to_owned(), "text/plain".to_owned())]
);
}
#[test]
fn a_plugin_cannot_control_response_framing() {
for name in [
"content-length",
"transfer-encoding",
"connection",
"upgrade",
"keep-alive",
"te",
"trailer",
"proxy-authenticate",
"content-encoding",
] {
let response = SandboxResponse {
status: 200,
headers: vec![(name.to_owned(), "x".to_owned())],
body: vec![],
};
let (clean, denied) = response.sanitize("/hello", &hello_routes(), "GET");
assert_eq!(denied, vec![name.to_owned()], "{name} must be stripped");
assert!(clean.headers.is_empty());
}
}
#[test]
fn a_proxy_injected_identity_header_never_reaches_the_guest() {
let mut req = request();
req.headers = vec![
("Accept".to_owned(), "text/plain".to_owned()),
("Cf-Access-Jwt-Assertion".to_owned(), "ey.J.hdr".to_owned()),
("X-Forwarded-User".to_owned(), "ada".to_owned()),
("X-Amzn-Oidc-Data".to_owned(), "ey.J.aws".to_owned()),
("X-Ms-Client-Principal".to_owned(), "ey.J.ms".to_owned()),
("X-Goog-Iap-Jwt-Assertion".to_owned(), "ey.J.g".to_owned()),
("X-Trace".to_owned(), "abc".to_owned()),
];
let HostFrame::Request { headers, .. } =
HostFrame::request(&req, &[SandboxCapability::HttpRequest])
else {
panic!("`HostFrame::request` builds a request frame")
};
assert_eq!(
headers,
vec![("accept".to_owned(), "text/plain".to_owned())],
"only ordinary request metadata may cross"
);
}
#[test]
fn the_request_allowlist_carries_what_a_handler_needs() {
let mut req = request();
req.headers = vec![
("accept".to_owned(), "application/json".to_owned()),
("accept-language".to_owned(), "en".to_owned()),
("content-type".to_owned(), "application/json".to_owned()),
("if-none-match".to_owned(), "\"abc\"".to_owned()),
("range".to_owned(), "bytes=0-99".to_owned()),
("user-agent".to_owned(), "curl".to_owned()),
];
let HostFrame::Request { headers, .. } =
HostFrame::request(&req, &[SandboxCapability::HttpRequest])
else {
panic!("`HostFrame::request` builds a request frame")
};
assert_eq!(headers.len(), 6, "{headers:?}");
}
#[test]
fn a_second_content_type_cannot_smuggle_a_document_past_the_allowlist() {
let response = SandboxResponse {
status: 200,
headers: vec![
("content-type".to_owned(), "text/plain".to_owned()),
("content-type".to_owned(), "text/html".to_owned()),
],
body: b"<script>alert(1)</script>".to_vec(),
};
assert!(
response.refused_content_type().is_some(),
"a second content-type crossed the boundary unchecked"
);
let response = SandboxResponse {
status: 200,
headers: vec![
("content-type".to_owned(), "text/html".to_owned()),
("content-type".to_owned(), "text/plain".to_owned()),
],
body: Vec::new(),
};
assert!(response.refused_content_type().is_some());
let response = SandboxResponse {
status: 200,
headers: vec![("content-type".to_owned(), "text/plain".to_owned())],
body: Vec::new(),
};
assert_eq!(response.refused_content_type(), None);
}
#[test]
fn a_plugin_cannot_reach_a_reverse_proxy_through_an_x_header() {
for name in [
"x-accel-redirect",
"x-sendfile",
"x-accel-buffering",
"x-autumn-sandboxed",
"x-content-type-options",
"x-whatever",
] {
assert!(
!response_header_allowed(name),
"{name} must not be settable by a plugin"
);
}
for name in ["content-type", "cache-control", "etag", "location", "vary"] {
assert!(response_header_allowed(name), "{name} must be settable");
}
}
#[test]
fn a_header_that_would_split_the_response_is_refused() {
for (name, value) in [
("x-evil", "a\r\nSet-Cookie: forged=1"),
("x-evil\r\nSet-Cookie: forged=1", "a"),
("x-evil", "a\nb"),
("", "a"),
("x evil", "a"),
] {
let response = SandboxResponse {
status: 200,
headers: vec![(name.to_owned(), value.to_owned())],
body: vec![],
};
assert!(
response.validate().is_err(),
"header {name:?}: {value:?} must be refused"
);
}
}
#[test]
fn a_response_frame_carrying_more_headers_than_the_cap_is_refused_while_parsing() {
let headers = std::iter::repeat_n(r#"["",""]"#, MAX_RESPONSE_HEADERS + 1)
.collect::<Vec<_>>()
.join(",");
let line = format!(r#"{{"status":200,"headers":[{headers}],"body_b64":""}}"#);
let parsed = from_line::<SandboxResponse>(&line);
assert!(
parsed.is_err(),
"a frame past the header cap must not deserialize"
);
let headers = std::iter::repeat_n(r#"["x","y"]"#, MAX_RESPONSE_HEADERS)
.collect::<Vec<_>>()
.join(",");
let line = format!(r#"{{"status":200,"headers":[{headers}],"body_b64":""}}"#);
let parsed = from_line::<SandboxResponse>(&line).expect("a frame at the cap parses");
assert_eq!(parsed.headers.len(), MAX_RESPONSE_HEADERS);
}
struct WidestWrite(usize);
impl std::io::Write for WidestWrite {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.max(buf.len());
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn a_body_is_encoded_into_the_line_without_a_whole_body_temporary() {
const BODY: usize = 1 << 20;
let mut big = request();
big.body = vec![0xA5; BODY];
let frame = HostFrame::request(&big, &[]);
let mut widest = WidestWrite(0);
serde_json::to_writer(&mut widest, &frame).expect("serializes");
assert!(
widest.0 < BODY / 16,
"one write carried {} bytes of a {BODY}-byte body",
widest.0
);
let line = to_line(&frame).expect("serializes");
let HostFrame::Request { body, .. } = from_line(line.trim_end()).expect("parses") else {
panic!("a request frame must parse back as a request");
};
assert_eq!(body, big.body);
}
#[test]
fn a_line_holds_no_spare_capacity() {
let mut big = request();
big.body = vec![0x5A; (1 << 20) + 17];
let line = to_exact_line(&HostFrame::request(&big, &[])).expect("serializes");
assert_eq!(line.capacity(), line.len());
}
#[test]
fn a_response_over_the_ceiling_is_refused() {
let response = SandboxResponse {
status: 200,
headers: vec![],
body: vec![0u8; 128],
};
assert!(response.check_size(64).is_err());
assert!(response.check_size(4096).is_ok());
}
}