//! 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 { 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, url: String, chain: Vec) -> 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 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, length: usize, status: u16, port: u16, location: Option, ) -> (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| { 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") ); }