Files
sausage/host/tests/hook.rs
T

364 lines
13 KiB
Rust

//! Real guest HTTP requests pass through caller identity, credential injection, and streaming transport.
mod hook_git;
mod hook_guest;
use sausage::{
cred::{
self,
items::{Field, Item, Rule},
types::FieldType,
},
exec::{Command, Exec, Options},
process::Runtime,
world::World,
};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
struct Fake;
impl cred::script::Script for Fake {
fn evaluate(
&self,
_: &str,
input: &str,
_: Option<&str>,
_: cred::script::Limits,
) -> Result<String, cred::script::Error> {
let context: serde_json::Value = serde_json::from_str(input).unwrap();
Ok(serde_json::json!({"headers":[{"name":"authorization","value":context["credential"]["token"]}]}).to_string())
}
}
async fn run(runtime: &Arc<Runtime>, url: String, chain: Vec<String>) -> String {
let mut out = Vec::new();
let code = runtime
.exec(
Command::Argv(vec!["/bin/guest.wasm".into(), url, "GET".into()]),
Options {
cwd: "/".into(),
conversations: chain,
timeout: Some(std::time::Duration::from_secs(30)),
..Default::default()
},
&mut |_, b| out.extend_from_slice(b),
)
.await
.unwrap();
assert_eq!(code, 0);
String::from_utf8(out).unwrap()
}
#[tokio::test]
async fn identity_injection_denial_and_ambiguity() {
let guest = hook_guest::component();
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
std::fs::create_dir_all(temp.path().join("world/bin")).unwrap();
std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let c = Arc::new(cred::Cred::open(&world.host_dir).unwrap());
let caller = cred::Caller::default();
let item = c
.create_item(
Item {
id: String::new(),
name: "test item".into(),
version: 0,
free_use: true,
rules: vec![Rule {
port: Some(port),
scheme: "http".into(),
hosts: vec!["127.0.0.1".into()],
path_prefixes: vec!["/free".into()],
methods: None,
}],
validate_js: None,
transform_js: "inject".into(),
fields: vec![Field {
field_id: String::new(),
name: "token".into(),
public: false,
value: None,
needs_value: true,
field_type: FieldType::Text,
key_type: None,
}],
},
&caller,
c.unlocked().unwrap(),
)
.unwrap();
let token = uuid::Uuid::new_v4().to_string();
c.set_field(
&item.id,
&item.fields[0].field_id,
&token,
&caller,
c.unlocked().unwrap(),
)
.unwrap();
let runtime = Runtime::new_with_hooks(world, c.clone(), Arc::new(Fake), None).unwrap();
let identity = run(
&runtime,
"http://host.sausage/whoami".into(),
vec!["child".into(), "root".into()],
)
.await;
let who: serde_json::Value =
serde_json::from_str(identity.lines().find(|line| line.starts_with('{')).unwrap()).unwrap();
assert_eq!(who["pid"], 1);
assert_eq!(who["program"], "/bin/guest.wasm");
assert_eq!(who["conversations"], serde_json::json!(["child", "root"]));
std::os::unix::fs::symlink("guest.wasm", temp.path().join("world/bin/alias")).unwrap();
let mut alias_out = Vec::new();
let code = runtime
.exec(
Command::Argv(vec![
"/bin/alias".into(),
"custom://host.sausage/whoami".into(),
"GET".into(),
]),
Options {
cwd: "/".into(),
..Default::default()
},
&mut |_, b| alias_out.extend_from_slice(b),
)
.await
.unwrap();
assert_eq!(code, 0);
let alias_out = String::from_utf8(alias_out).unwrap();
assert!(alias_out.contains("/bin/guest.wasm") && !alias_out.contains("/bin/alias"));
let items = run(&runtime, "http://host.sausage/cred/items".into(), vec![]).await;
assert!(items.contains("test item") && !items.contains(&token));
let server_token = token.clone();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
loop {
let mut b = [0; 1024];
let n = socket.read(&mut b).await.unwrap();
assert!(n > 0);
bytes.extend_from_slice(&b[..n]);
if bytes.ends_with(b"0\r\n\r\n")
|| (bytes.ends_with(b"\r\n\r\n")
&& !String::from_utf8_lossy(&bytes).contains("transfer-encoding: chunked"))
{
break;
}
}
let text = String::from_utf8(bytes).unwrap();
assert!(
text.contains(&server_token),
"server did not receive the credential"
);
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
.await
.unwrap();
});
let url = format!("http://127.0.0.1:{port}/free");
let out = run(&runtime, url.clone(), vec!["root".into()]).await;
assert!(out.contains("status=200") && !out.contains(&token));
server.await.unwrap();
let (public_url, public) = upstream(None, 0, 200, 0, None).await;
let location = format!("{}/public", public_url.replace("127.0.0.1", "localhost"));
let (redirect_url, redirect) =
upstream(Some(token.clone()), 0, 302, port, Some(location)).await;
let mut follow_out = Vec::new();
let code = runtime
.exec(
Command::Argv(vec![
"/bin/guest.wasm".into(),
"follow".into(),
format!("{redirect_url}/free"),
]),
Options {
cwd: "/".into(),
timeout: Some(std::time::Duration::from_secs(30)),
..Default::default()
},
&mut |_, b| follow_out.extend_from_slice(b),
)
.await
.unwrap();
assert_eq!(code, 0);
let follow_out = String::from_utf8(follow_out).unwrap();
assert!(
follow_out.contains("first-status=302")
&& follow_out.contains("status=200")
&& !follow_out.contains(&token)
);
redirect.await.unwrap();
public.await.unwrap();
let mut changed = item.clone();
changed.free_use = false;
// A second matching item must fail before any network request.
changed.id = String::new();
changed.name = "second".into();
changed.rules[0].path_prefixes = vec!["/free".into(), "/deny".into()];
let second = c
.create_item(changed, &caller, c.unlocked().unwrap())
.unwrap();
assert!(
run(&runtime, url.clone(), vec![])
.await
.contains("ambiguous_item")
);
let url = format!("http://127.0.0.1:{port}/deny");
let out = run(&runtime, url.clone(), vec!["other".into()]).await;
assert!(out.contains("status=403") && out.contains("second") && out.contains("request"));
let again = run(&runtime, url, vec!["other".into()]).await;
assert!(again.contains("status=403"));
assert_eq!(c.request_list().unwrap().len(), 1);
c.set_field(
&second.id,
&second.fields[0].field_id,
&token,
&caller,
c.unlocked().unwrap(),
)
.unwrap();
c.approve(
&c.request_list().unwrap()[0].id,
cred::webauthn::Decision::Approve,
cred::requests::Overrides::default(),
c.unlocked().unwrap(),
)
.unwrap();
let (url, task) = upstream(Some(token.clone()), 0, 200, port, None).await;
let out = run(
&runtime,
format!("{url}/deny"),
vec!["descendant".into(), "other".into()],
)
.await;
assert!(out.contains("status=200") && !out.contains(&token));
task.await.unwrap();
let (child_url, task) = upstream(Some(token.clone()), 0, 200, port, None).await;
let mut child_out = Vec::new();
let code = runtime
.exec(
Command::Argv(vec![
"/bin/guest.wasm".into(),
"spawn".into(),
format!("{child_url}/deny"),
]),
Options {
cwd: "/".into(),
conversations: vec!["descendant".into(), "other".into()],
timeout: Some(std::time::Duration::from_secs(30)),
..Default::default()
},
&mut |_, b| child_out.extend_from_slice(b),
)
.await
.unwrap();
assert_eq!(code, 0);
let child_out = String::from_utf8(child_out).unwrap();
assert!(child_out.contains("status=200") && !child_out.contains(&token));
task.await.unwrap();
let denied = run(&runtime, format!("{url}/deny"), vec!["sibling".into()]).await;
assert!(denied.contains("status=403"));
}
async fn upstream(
token: Option<String>,
length: usize,
status: u16,
port: u16,
location: Option<String>,
) -> (String, tokio::task::JoinHandle<()>) {
use http_body_util::{BodyExt, Full};
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
.await
.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let service =
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
let token = token.clone();
let location = location.clone();
async move {
match token {
Some(token) => assert!(
request
.headers()
.get("authorization")
.is_some_and(|h| h.as_bytes() == token.as_bytes()),
"credential absent or incorrect"
),
None => assert!(!request.headers().contains_key("authorization")),
}
let mut body = request.into_body();
let mut received = 0;
while let Some(frame) = body.frame().await {
if let Ok(data) = frame.unwrap().into_data() {
received += data.len();
}
}
assert_eq!(received, length);
Ok::<_, std::convert::Infallible>(
http::Response::builder()
.status(status)
.header(
"location",
location.as_deref().unwrap_or("http://localhost/public"),
)
.body(Full::new(bytes::Bytes::from_static(b"ok")))
.unwrap(),
)
}
});
hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.serve_connection(hyper_util::rt::TokioIo::new(socket), service)
.await
.unwrap();
});
(url, task)
}
#[tokio::test]
async fn streamed_upload_redirect_and_http_admin_denial() {
let guest = hook_guest::component();
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
std::fs::create_dir_all(temp.path().join("world/bin")).unwrap();
std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap();
let runtime = Runtime::new(world).unwrap();
let (url, task) = upstream(None, 4 * 1024 * 1024, 302, 0, None).await;
let mut out = Vec::new();
let code = runtime
.exec(
Command::Argv(vec![
"/bin/guest.wasm".into(),
url,
"POST".into(),
(4 * 1024 * 1024).to_string(),
]),
Options {
cwd: "/".into(),
timeout: Some(std::time::Duration::from_secs(30)),
..Default::default()
},
&mut |_, b| out.extend_from_slice(b),
)
.await
.unwrap();
assert_eq!(code, 0);
assert!(String::from_utf8(out).unwrap().contains("status=302"));
task.await.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
runtime
.network
.deny_host_port(listener.local_addr().unwrap().port());
assert!(
run(
&runtime,
format!("http://{}/", listener.local_addr().unwrap()),
vec![]
)
.await
.contains("error")
);
}