370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
//! Real guest HTTP requests pass through caller identity, credential injection, and streaming transport.
|
|
mod hook_git {
|
|
//! Preserve the Git push registration that also ran inside the former hook executable.
|
|
#[tokio::test]
|
|
async fn several_mb_git_push() {
|
|
crate::hook_git::push().await;
|
|
}
|
|
}
|
|
use super::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")
|
|
);
|
|
}
|