Merge v3-t29: wasi:http hook, credential injection, guest TCP/DNS with the admin rule, one shared host API (T29)

This commit is contained in:
2026-11-07 00:27:30 +00:00
26 changed files with 1634 additions and 137 deletions
Generated
+26 -8
View File
@@ -3251,6 +3251,16 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "if-addrs"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "if_chain"
version = "1.0.3"
@@ -5020,6 +5030,11 @@ dependencies = [
"hkdf",
"hmac",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"if-addrs",
"openssl",
"p256",
"rsa",
@@ -5036,6 +5051,8 @@ dependencies = [
"thiserror 2.0.21",
"tiny_http",
"tokio",
"tokio-rustls",
"toml",
"url",
"uuid",
"wasmtime",
@@ -5044,6 +5061,7 @@ dependencies = [
"wat",
"webauthn-authenticator-rs",
"webauthn-rs",
"webpki-roots",
"zeroize",
]
@@ -5222,9 +5240,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
dependencies = [
"serde_core",
]
@@ -6412,9 +6430,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
version = "1.2.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
dependencies = [
"indexmap",
"serde_core",
@@ -6427,9 +6445,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49"
dependencies = [
"serde_core",
]
@@ -6457,9 +6475,9 @@ dependencies = [
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
version = "1.2.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
[[package]]
name = "tower"
+8
View File
@@ -53,6 +53,14 @@ webauthn-rs = "0.5"
openssl = { version = "0.10.82", features = ["vendored"] }
aws-lc-rs = "1.19.0"
bytes = "1.12.1"
tokio-rustls = "0.26.6"
webpki-roots = "1.0.11"
if-addrs = "0.15.0"
toml = "1.2.1"
http-body = "1.1.0"
http-body-util = "0.1.5"
hyper-util = { version = "0.1.21", features = ["tokio"] }
hyper = { version = "1.12.0", features = ["client", "http1", "server"] }
tiny_http = "0.12"
[dev-dependencies]
+7 -5
View File
@@ -1,7 +1,7 @@
# Host API and host pages
`handle(&Api, Caller, Request<Bytes>)` answers requests without a transport.
The later `wasi:http` hook supplies the caller from the process table.
The `wasi:http` hook supplies the caller from the process table.
It must not accept caller identity from headers, environment variables, or JSON.
Reads are open to every caller.
JSON routes return 200 on success.
@@ -11,11 +11,13 @@ Conflicts return 409. The filing budget returns 429.
## Host listener
`sausage run WORLD` starts the console and the host listener with one shared `Arc<Cred>`.
The listener owns its passkey connection and handles requests on a dedicated thread.
The console receives the same credentials store, including its decision notifications.
`sausage run WORLD` creates one `Cred`, one `Arc<Api>`, and one process runtime.
The API owns its passkey connection and retains ceremony and rate-limit state.
The hook and the host listener share that API.
The listener handles requests on a dedicated thread.
The console shares the credential store, including its decision notifications.
After the listener binds, the runtime denies its port on every local address.
The listener stops when the console returns.
No protected harness or process module needs a change.
Options on `sausage run`:
+3 -3
View File
@@ -15,7 +15,7 @@ fn choice(body: &Value) -> Result<(Decision, Overrides)> {
}
Ok((decision, overrides))
}
fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
fn verify(ctx: &Api, body: &Value) -> Result<Verified> {
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
@@ -25,7 +25,7 @@ fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
)
.map_err(|_| Error(403, "assertion_failed"))
}
pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::items::Field> {
pub(super) fn field(ctx: &Api, item: &str, field: &str) -> Result<cred::items::Field> {
let field = ctx
.cred
.item_get(item)?
@@ -39,7 +39,7 @@ pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::item
Ok(field)
}
pub(super) fn route(
ctx: &Api<'_>,
ctx: &Api,
caller: &Caller,
method: &str,
path: &[&str],
+26 -8
View File
@@ -17,16 +17,34 @@ use std::{
time::{Duration, Instant},
};
pub struct Api<'a> {
pub struct Api {
pub cred: Arc<Cred>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<'a>>,
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
}
impl<'a> Api<'a> {
impl Api {
/// Own the connection and ceremony state shared by both transports.
pub fn open(
cred: Arc<Cred>,
host_dir: &std::path::Path,
rp_id: &str,
origin: &str,
) -> anyhow::Result<Self> {
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
Ok(Self::new(
cred,
Passkeys::new(db, rp_id, origin)?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
}
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
pub fn new(
cred: impl Into<Arc<Cred>>,
passkeys: Passkeys<rusqlite::Connection>,
entry: EntryKey,
) -> Self {
Self {
cred: cred.into(),
script: cred::quickjs::QuickJs::default(),
@@ -113,10 +131,10 @@ fn request_view(request: cred::requests::Request) -> Result<Value> {
}
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
pub async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
respond(ctx, caller, request)
}
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = if request.method() == http::Method::GET {
match pages::get(ctx, request.uri().path()) {
Ok(Some(response)) => return response,
@@ -143,7 +161,7 @@ fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<B
.body(Bytes::from(serde_json::to_vec(&body).expect("JSON value")))
.expect("valid response")
}
fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
fn route(ctx: &Api, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect();
let method = request.method().as_str();
let body: Value = if request.body().is_empty() {
@@ -206,7 +224,7 @@ mod tests {
let db = rusqlite::Connection::open_in_memory()?;
let api = Api::new(
Cred::open(dir.path())?,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
Passkeys::new(db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let caller = Caller::default();
+1 -1
View File
@@ -10,7 +10,7 @@ fn escape(text: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result<Option<Response<Bytes>>> {
pub(super) fn get(ctx: &Api, path: &str) -> Result<Option<Response<Bytes>>> {
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
let (mime, text) = match parts.as_slice() {
["app.js"] => (
+67 -65
View File
@@ -1,6 +1,6 @@
//! The host HTTP listener and console startup with one shared credentials store.
//! The host HTTP listener and console startup share one owned API and credentials store.
use super::{Api, respond};
use crate::cred::{Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use crate::cred::{Caller, Cred};
use std::{net::SocketAddr, path::Path, sync::Arc};
#[derive(Clone, Debug, clap::Args)]
@@ -22,71 +22,58 @@ pub struct Listener {
}
impl Listener {
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
let api = Arc::new(Api::open(
cred,
host_dir,
&options.api_rp_id,
&options.api_origin,
)?);
Self::start_with_api(api, options)
}
pub fn start_with_api(api: Arc<Api>, options: Options) -> anyhow::Result<Self> {
let server = Arc::new(
tiny_http::Server::http(options.api_listen)
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
);
let serving = server.clone();
let host_dir = host_dir.to_owned();
let (ready, started) = std::sync::mpsc::sync_channel(1);
let thread = std::thread::spawn(move || {
let setup = || -> anyhow::Result<_> {
Ok((
rusqlite::Connection::open(host_dir.join("cred.sqlite"))?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
};
let serve = || -> anyhow::Result<()> {
let (db, entry) = setup()?;
let passkeys = Passkeys::new(&db, &options.api_rp_id, &options.api_origin)?;
let api = Api::new(cred, passkeys, entry);
let _ = ready.send(true);
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body)?;
let request = http::Request::builder()
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(respond(
&api,
Caller {
program: "/host/browser".into(),
pid: std::process::id(),
conversations: Vec::new(),
},
request,
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body)?;
let request = http::Request::builder()
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(respond(
&api,
Caller {
program: "/host/browser".into(),
pid: std::process::id(),
conversations: Vec::new(),
},
request,
))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
.status(400)
.header("content-type", "application/json")
.body(bytes::Bytes::from_static(
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
.status(400)
.header("content-type", "application/json")
.body(bytes::Bytes::from_static(
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
))
.expect("static response")
});
let content_type = response.headers()["content-type"].as_bytes();
let reply = tiny_http::Response::from_data(response.body().to_vec())
.with_status_code(response.status().as_u16())
.with_header(
tiny_http::Header::from_bytes(b"Content-Type", content_type)
.expect("static header"),
);
let _ = request.respond(reply);
}
Ok(())
};
if serve().is_err() {
let _ = ready.send(false);
.expect("static response")
});
let content_type = response.headers()["content-type"].as_bytes();
let reply = tiny_http::Response::from_data(response.body().to_vec())
.with_status_code(response.status().as_u16())
.with_header(
tiny_http::Header::from_bytes(b"Content-Type", content_type)
.expect("static header"),
);
let _ = request.respond(reply);
}
});
if !started.recv().unwrap_or(false) {
let _ = thread.join();
anyhow::bail!("host API initialization failed");
}
Ok(Self {
server,
thread: Some(thread),
@@ -105,7 +92,7 @@ impl Drop for Listener {
}
}
/// Preserve the console lifecycle while sharing Cred with the host page listener.
/// Preserve the console lifecycle with one API for guests and host pages.
pub async fn run_with_userland(
path: &Path,
userland: Option<&Path>,
@@ -113,12 +100,26 @@ pub async fn run_with_userland(
) -> anyhow::Result<()> {
let world = crate::world::World::open_with_userland(path, userland)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let _listener = Listener::start(cred.clone(), &world.host_dir, options)?;
let runtime = crate::process::Runtime::new(crate::world::World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
&options.api_rp_id,
&options.api_origin,
)?);
let runtime = crate::process::Runtime::new_with_hooks(
crate::world::World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
crate::process::default_script(),
Some(api.clone()),
)?;
let listener = Listener::start_with_api(api, options)?;
runtime
.network_policy()
.deny_host_port(listener.address().port());
let services = crate::process::supervise(runtime.clone());
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
@@ -146,5 +147,6 @@ pub async fn run_with_userland(
anyhow::anyhow!(text)
});
services.shutdown().await;
drop(listener);
result
}
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::cred::{self, Caller, script::Script, types::FieldType};
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
pub(super) fn handle(ctx: &Api, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
let cred = &ctx.cred;
let host = string(body, "host")?;
let mut items = cred.item_list()?.into_iter().filter(|item| {
+11 -7
View File
@@ -155,17 +155,18 @@ struct Pending<T> {
deadline: Instant,
value: T,
}
pub struct Passkeys<'a> {
db: &'a Connection,
/// Own the connection for a shared API, or borrow it for a local caller.
pub struct Passkeys<D: std::borrow::Borrow<Connection>> {
db: D,
webauthn: Webauthn,
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
}
impl<'a> Passkeys<'a> {
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
impl<D: std::borrow::Borrow<Connection>> Passkeys<D> {
pub fn new(db: D, rp_id: &str, origin: &str) -> Result<Self> {
let origin = Url::parse(origin)?;
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
db.execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?;
db.borrow().execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?;
Ok(Self {
db,
webauthn,
@@ -176,6 +177,7 @@ impl<'a> Passkeys<'a> {
/// Database errors leave the manager locked.
pub fn is_unlocked(&self) -> bool {
self.db
.borrow()
.query_row(
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
[],
@@ -187,6 +189,7 @@ impl<'a> Passkeys<'a> {
pub fn list(&self) -> Result<Vec<serde_json::Value>> {
let mut statement = self
.db
.borrow()
.prepare("SELECT id,label FROM passkey_credentials ORDER BY rowid")?;
let rows = statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
@@ -201,6 +204,7 @@ impl<'a> Passkeys<'a> {
fn credentials(&self) -> Result<Vec<Passkey>> {
let mut q = self
.db
.borrow()
.prepare("SELECT credential FROM passkey_credentials")?;
q.query_map([], |r| r.get::<_, String>(0))?
.map(|r| Ok(serde_json::from_str(&r?)?))
@@ -261,7 +265,7 @@ impl<'a> Passkeys<'a> {
let credential = self
.webauthn
.finish_passkey_registration(&response, state)?;
self.db.execute(
self.db.borrow().execute(
"INSERT INTO passkey_credentials(id,label,credential) VALUES(?1,?2,?3)",
params![
serde_json::to_string(credential.cred_id())?,
@@ -313,7 +317,7 @@ impl<'a> Passkeys<'a> {
bail!("passkey counter did not increase");
}
credential.update_credential(&result);
self.db.execute(
self.db.borrow().execute(
"UPDATE passkey_credentials SET credential=?1 WHERE id=?2",
params![
serde_json::to_string(credential)?,
+2 -3
View File
@@ -10,14 +10,13 @@ pub enum Command {
}
/// How to run it. `cwd` is a world path. `timeout` ends the command and all its processes.
/// `conversation` is the harness conversation that runs the command. Its processes and their
/// descendants carry it, so the host knows the caller (for example in the `wasi:http` hook).
/// `conversations` carries the nearest-first conversation chain. Child processes inherit it.
#[derive(Clone, Debug, Default)]
pub struct Options {
pub cwd: String,
pub env: Vec<(String, String)>,
pub timeout: Option<Duration>,
pub conversation: Option<String>,
pub conversations: Vec<String>,
}
/// The stream that one output chunk came from.
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn op_exec(
};
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let task = tokio::spawn(async move {
let result = exec.exec(command, Options { cwd, conversation, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
let result = exec.exec(command, Options { cwd, conversations: conversation.into_iter().collect(), timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
let _ = sender.send(match result {
Ok(exit) => json!({"exitCode": exit}),
Err(e) => json!({"error": e.to_string()}),
+26 -7
View File
@@ -92,12 +92,25 @@ deno_core::extension!(
/// Start the console with the process implementation supplied by the host.
pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<()> {
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
run_with_shared_credentials(world, exec, cred).await
}
async fn run_with_shared_credentials(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::Cred>,
) -> anyhow::Result<()> {
// Deno's unsynchronized operations require a current-thread Tokio runtime.
tokio::task::spawn_blocking(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(run_module(world, exec, include_str!("../../js/bundle.js")))
.block_on(run_module_with_credentials(
world,
exec,
cred,
include_str!("../../js/bundle.js"),
))
})
.await?
.map_err(|error: anyhow::Error| {
@@ -232,13 +245,19 @@ pub async fn run(path: &Path) -> anyhow::Result<()> {
/// Start the console with the shared process table and an optional userland bundle.
pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
let world = World::open_with_userland(path, userland)?;
let runtime = crate::process::Runtime::new(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
let runtime = crate::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
crate::process::default_script(),
None,
)?;
let services = crate::process::supervise(runtime.clone());
let result = run_with_exec(world, Arc::new(runtime)).await;
let result = run_with_shared_credentials(world, Arc::new(runtime), cred).await;
services.shutdown().await;
result
}
+376
View File
@@ -0,0 +1,376 @@
//! Per-process credential hook and pinned-address HTTP transport.
//! Transport derives from Wasmtime 49's default sender, with a policy check before connect.
use crate::cred::{Caller, Cred, inject::Outcome, script::Script};
use http_body_util::{BodyExt, Full};
use std::sync::Arc;
use wasmtime_wasi_http::{Error, RequestOptions};
use wasmtime_wasi_http::{WasiBody, WasiHttpHooks};
type Io = Box<dyn Future<Output = Result<(), Error>> + Send>;
pub type Router = Arc<crate::api::Api>;
pub(super) struct Hooks {
pub caller: Caller,
pub cred: Arc<Cred>,
pub script: Arc<dyn Script>,
pub policy: Arc<super::net::NetPolicy>,
pub router: Router,
}
fn response(response: Response<Bytes>) -> (Response<WasiBody>, Io) {
(
response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()),
Box::new(async { Ok(()) }),
)
}
impl WasiHttpHooks for Hooks {
// Admit every scheme until the URI supplies the host name.
fn is_supported_scheme(&mut self, _: &http::uri::Scheme) -> bool {
true
}
fn send_request(
&mut self,
request: Request<WasiBody>,
options: Option<RequestOptions>,
fut: Io,
) -> Box<dyn Future<Output = Result<(Response<WasiBody>, Io), Error>> + Send> {
drop(fut);
let caller = self.caller.clone();
let cred = self.cred.clone();
let script = self.script.clone();
let policy = self.policy.clone();
let router = self.router.clone();
Box::new(async move {
let (parts, mut body) = request.into_parts();
if parts
.uri
.host()
.is_some_and(|h| h.eq_ignore_ascii_case("host.sausage"))
{
let bytes = body.collect().await?.to_bytes();
return Ok(response(
crate::api::handle(&router, caller, Request::from_parts(parts, bytes)).await,
));
}
if !matches!(parts.uri.scheme_str(), Some("http" | "https")) {
while let Some(frame) = body.frame().await {
frame?;
}
return Err(Error::HttpRequestUriInvalid);
}
let result =
tokio::task::spawn_blocking(move || cred.inject(parts, &caller, script.as_ref()))
.await;
let mut parts = match result {
Ok(Ok(Outcome::Forward(parts))) => parts,
outcome => {
// Drain without retaining uploads, so the guest can finish its output stream.
while let Some(frame) = body.frame().await {
frame?;
}
let (status, value) = match outcome {
Ok(Ok(Outcome::Denied { item, request })) => (
403,
serde_json::json!({"message": format!("Access to {item} needs approval. Request {request} awaits a decision."), "item": item, "request": request}),
),
Ok(Err(e)) => (502, serde_json::json!({"error": e.to_string()})),
_ => (
502,
serde_json::json!({"error": "credential worker failed"}),
),
};
return Ok(response(
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Bytes::from(serde_json::to_vec(&value).unwrap()))
.unwrap(),
));
}
};
let authority = parts.uri.authority().ok_or(Error::HttpRequestUriInvalid)?;
if authority.as_str().contains('@') {
return Err(Error::HttpRequestUriInvalid);
}
parts.headers.insert(
http::header::HOST,
authority
.as_str()
.parse()
.map_err(|_| Error::HttpRequestUriInvalid)?,
);
for value in parts.headers.values_mut() {
value.set_sensitive(true);
}
// Hyper otherwise discards unknown-length GET bodies before WASI finishes them.
if !parts.headers.contains_key(http::header::CONTENT_LENGTH) {
parts.headers.insert(
http::header::TRANSFER_ENCODING,
http::HeaderValue::from_static("chunked"),
);
}
let (res, io) =
send_request(Request::from_parts(parts, body), options, &policy).await?;
Ok((res.map(BodyExt::boxed_unsync), Box::new(io) as Io))
})
}
}
use bytes::Bytes;
use core::future::poll_fn;
use core::pin::{Pin, pin};
use core::task::{Poll, ready};
use http::uri::Scheme;
use http::{Request, Response};
use http_body::Body;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
trait TokioStream: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static {
fn boxed(self) -> Box<dyn TokioStream>
where
Self: Sized,
{
Box::new(self)
}
}
impl<T> TokioStream for T where T: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static {}
/// Preserve Wasmtime's HTTP/1 transport and request deadlines, but connect only to checked addresses.
/// The returned future drives the connection after the response headers arrive.
async fn send_request<T: Body<Data = Bytes, Error = Error> + Send + 'static>(
mut req: Request<T>,
options: Option<RequestOptions>,
policy: &super::net::NetPolicy,
) -> Result<
(
Response<impl Body<Data = Bytes, Error = Error> + use<T>>,
impl Future<Output = Result<(), Error>> + Send + use<T>,
),
Error,
> {
let uri = req.uri().clone();
let authority = uri.authority().ok_or(Error::HttpRequestUriInvalid)?;
let use_tls = uri.scheme() == Some(&Scheme::HTTPS);
let authority = if authority.port().is_some() {
authority.to_string()
} else {
let port = if use_tls { 443 } else { 80 };
format!("{authority}:{port}")
};
let connect_timeout = options
.and_then(|r| r.connect_timeout)
.unwrap_or(Duration::from_secs(600));
let first_byte_timeout = options
.and_then(|r| r.first_byte_timeout)
.unwrap_or(Duration::from_secs(600));
let between_bytes_timeout = options
.and_then(|r| r.between_bytes_timeout)
.unwrap_or(Duration::from_secs(600));
let stream = match tokio::time::timeout(connect_timeout, async {
let addresses = policy.resolve(&uri).await?;
TcpStream::connect(addresses.as_slice())
.await
.map_err(Error::Connect)
})
.await
{
Ok(stream) => stream?,
Err(..) => return Err(Error::ConnectionTimeout),
};
let stream = if use_tls {
// derived from https://github.com/rustls/rustls/blob/main/examples/src/bin/simpleclient.rs
let root_cert_store = rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
};
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config));
let domain = tls_server_name(&authority)?;
let stream = connector
.connect(domain, stream)
.await
.map_err(Error::Tls)?;
stream.boxed()
} else {
stream.boxed()
};
let (mut sender, conn) = tokio::time::timeout(
connect_timeout,
hyper::client::conn::http1::Builder::new().handshake(hyper_util::rt::TokioIo::new(stream)),
)
.await
.map_err(|_| Error::ConnectionTimeout)??;
// Send origin-form URIs, because this connection does not use a proxy.
*req.uri_mut() = http::Uri::builder()
.path_and_query(
req.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/"),
)
.build()
.expect("comes from valid request");
let send = async move {
use core::task::Context;
/// Wrapper around [hyper::body::Incoming] used to
/// account for request option timeout configuration
struct IncomingResponseBody {
incoming: hyper::body::Incoming,
timeout: tokio::time::Interval,
}
impl http_body::Body for IncomingResponseBody {
type Data = <hyper::body::Incoming as http_body::Body>::Data;
type Error = Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
match Pin::new(&mut self.as_mut().incoming).poll_frame(cx) {
Poll::Ready(None) => Poll::Ready(None),
Poll::Ready(Some(Err(err))) => {
let err = if err.is_timeout() {
Error::HttpResponseTimeout
} else {
Error::from(err)
};
Poll::Ready(Some(Err(err)))
}
Poll::Ready(Some(Ok(frame))) => {
self.timeout.reset();
Poll::Ready(Some(Ok(frame)))
}
Poll::Pending => {
ready!(self.timeout.poll_tick(cx));
Poll::Ready(Some(Err(Error::ConnectionReadTimeout)))
}
}
}
fn is_end_stream(&self) -> bool {
self.incoming.is_end_stream()
}
fn size_hint(&self) -> http_body::SizeHint {
self.incoming.size_hint()
}
}
let res = tokio::time::timeout(first_byte_timeout, sender.send_request(req))
.await
.map_err(|_| Error::ConnectionReadTimeout)?
.map_err(Error::from)?;
// Tokio panics on an `interval` with a zero duration, so clamp it to a
// 1ns timeout which is sort of effectively zero anyway.
let between_bytes_timeout = if between_bytes_timeout == Duration::ZERO {
Duration::new(0, 1)
} else {
between_bytes_timeout
};
let mut timeout = tokio::time::interval(between_bytes_timeout);
timeout.reset();
Ok(res.map(|incoming| IncomingResponseBody { incoming, timeout }))
};
let mut send = pin!(send);
let mut conn = Some(conn);
// Wait for response while driving connection I/O
let res = poll_fn(|cx| match send.as_mut().poll(cx) {
Poll::Ready(Ok(res)) => Poll::Ready(Ok(res)),
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
Poll::Pending => {
// Response is not ready, poll `hyper` connection to drive I/O if it has not completed yet
let Some(fut) = conn.as_mut() else {
// `hyper` connection already completed
return Poll::Pending;
};
let res = ready!(Pin::new(fut).poll(cx));
// `hyper` connection completed, record that to prevent repeated poll
conn = None;
match res {
// `hyper` connection has successfully completed, optimistically poll for response
Ok(()) => send.as_mut().poll(cx),
// `hyper` connection has failed, return the error
Err(err) => Poll::Ready(Err(Error::from(err))),
}
}
})
.await?;
Ok((res, async move {
let Some(conn) = conn.take() else {
// `hyper` connection has already completed
return Ok(());
};
if let Err(err) = conn.await {
if err.is_timeout() {
return Err(Error::HttpResponseTimeout);
}
return Err(err.into());
}
Ok(())
}))
}
/// Resolve the rustls [`ServerName`] used for TLS certificate verification from
/// an outbound request `authority`.
///
/// `authority` is in `host:port` form, where an IPv6 `host` is wrapped in
/// brackets (for example `[::1]:443`). An IP literal is recognized by parsing
/// the whole authority as a [`SocketAddr`]; this handles the bracketed IPv6
/// form, which splitting on the first `:` would truncate. Anything else is
/// treated as a host name, with the port stripped off before it is handed to
/// rustls.
///
/// [`ServerName`]: rustls::pki_types::ServerName
/// [`SocketAddr`]: std::net::SocketAddr
pub(crate) fn tls_server_name(
authority: &str,
) -> Result<rustls::pki_types::ServerName<'static>, rustls::pki_types::InvalidDnsNameError> {
use rustls::pki_types::ServerName;
if let Ok(addr) = authority.parse::<std::net::SocketAddr>() {
return Ok(ServerName::from(addr.ip()));
}
let host = match authority.split_once(':') {
Some((host, _port)) => host,
None => authority,
};
Ok(ServerName::try_from(host)?.to_owned())
}
#[cfg(test)]
mod tls_server_name_tests {
use super::tls_server_name;
use rustls::pki_types::ServerName;
#[test]
fn resolves_server_name_from_authority() {
// Host names keep their host and drop the port.
assert_eq!(
tls_server_name("example.com:443").unwrap(),
ServerName::try_from("example.com").unwrap()
);
assert_eq!(
tls_server_name("example.com").unwrap(),
ServerName::try_from("example.com").unwrap()
);
// IP literals resolve to an `IpAddress` server name. The bracketed IPv6
// form must not be truncated at the first `:`.
assert_eq!(
tls_server_name("127.0.0.1:80").unwrap(),
ServerName::from(std::net::Ipv4Addr::LOCALHOST)
);
assert_eq!(
tls_server_name("[::1]:443").unwrap(),
ServerName::from(std::net::Ipv6Addr::LOCALHOST)
);
assert_eq!(
tls_server_name("[2001:db8::1]:8443").unwrap(),
ServerName::from("2001:db8::1".parse::<std::net::Ipv6Addr>().unwrap())
);
}
}
+101 -10
View File
@@ -47,6 +47,13 @@ mod bindings {
}
use bindings::sausage::process::host::{Host, HostChild, Request, Stdio};
mod cache;
mod http;
pub use http::Router;
pub mod net;
/// Credential scripts use T28's bounded QuickJS component.
pub fn default_script() -> Arc<dyn crate::cred::script::Script> {
Arc::new(crate::cred::quickjs::QuickJs::default())
}
pub use cache::configure as configure_cache;
mod services;
mod watcher;
@@ -87,6 +94,10 @@ pub struct Runtime {
entries: Mutex<HashMap<u32, Entry>>,
next: AtomicU32,
alive: Arc<AtomicBool>,
cred: Arc<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: http::Router,
pub network: Arc<net::NetPolicy>,
}
impl Drop for Runtime {
fn drop(&mut self) {
@@ -101,17 +112,18 @@ struct State {
pid: u32,
command: u32,
group: u32,
conversation: Option<String>,
conversations: Vec<String>,
stdio: Io,
preview1: Option<sausage_vfs::preview1::State>,
http: wasmtime_wasi_http::WasiHttpCtx,
hooks: http::Hooks,
}
impl wasmtime_wasi_http::WasiHttpView for State {
fn http(&mut self) -> wasmtime_wasi_http::WasiHttpCtxView<'_> {
wasmtime_wasi_http::WasiHttpCtxView {
ctx: &mut self.http,
table: &mut self.table,
hooks: Default::default(),
hooks: &mut self.hooks,
}
}
}
@@ -151,6 +163,35 @@ impl Runtime {
Self::with_compiler(world, cache::Compiler::new(directory)?)
}
fn with_compiler(world: World, compiler: Arc<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
Self::with_hooks(world, compiler, cred, default_script(), None)
}
/// Share credentials and the host API router with the host's other subsystems.
pub fn new_with_hooks(
world: World,
cred: Arc<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: Option<Router>,
) -> anyhow::Result<Arc<Self>> {
Self::with_hooks(world, cache::shared()?, cred, script, router)
}
fn with_hooks(
world: World,
compiler: Arc<cache::Compiler>,
cred: Arc<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: Option<http::Router>,
) -> anyhow::Result<Arc<Self>> {
let router = match router {
Some(api) => api,
None => Arc::new(crate::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?),
};
let network = Arc::new(net::NetPolicy::open(&world.host_dir)?);
let engine = compiler.engine.clone();
let alive = Arc::new(AtomicBool::new(true));
let ticking = alive.clone();
@@ -164,6 +205,10 @@ impl Runtime {
let runtime = Arc::new(Self {
world,
engine,
cred,
script,
router,
network,
alive,
compiler,
cache: Mutex::new(Default::default()),
@@ -173,6 +218,10 @@ impl Runtime {
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
Ok(runtime)
}
/// Register native listener ports through the shared network policy.
pub fn network_policy(&self) -> &net::NetPolicy {
&self.network
}
/// Number of distinct program contents compiled by this runtime.
pub fn compiled_programs(&self) -> usize {
self.cache.lock().unwrap().len()
@@ -203,7 +252,7 @@ impl Runtime {
for path in paths {
for candidate in [path.clone(), format!("{path}.wasm")] {
match self.world.root.stat(&candidate, true).await {
Ok(stat) if !stat.directory => return Ok(candidate),
Ok(stat) if !stat.directory => return self.canonical_program(candidate).await,
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
@@ -212,6 +261,32 @@ impl Runtime {
}
anyhow::bail!("program not found: {name}")
}
async fn canonical_program(&self, mut path: String) -> anyhow::Result<String> {
for _ in 0..40 {
let mut prefix = String::new();
let components: Vec<_> = path.trim_start_matches('/').split('/').collect();
let mut target = None;
for (index, component) in components.iter().enumerate() {
prefix.push('/');
prefix.push_str(component);
if self.world.root.stat(&prefix, false).await?.symlink {
let link = self.world.root.readlink(&prefix).await?;
let parent = prefix.rsplit_once('/').unwrap().0;
let resolved = sausage_vfs::resolve(parent, &link);
target = Some(sausage_vfs::resolve(
&resolved,
&components[index + 1..].join("/"),
));
break;
}
}
match target {
Some(target) => path = target,
None => return Ok(path),
}
}
anyhow::bail!("too many program symlinks")
}
fn kill_command(&self, command: u32) {
for entry in self.entries.lock().unwrap().values() {
if entry.command == command {
@@ -244,7 +319,7 @@ impl Runtime {
parent: u32,
command: u32,
inherited_group: u32,
conversation: Option<String>,
conversations: Vec<String>,
inherited_io: Option<Io>,
) -> anyhow::Result<Started> {
anyhow::ensure!(!request.argv.is_empty(), "empty argv");
@@ -329,9 +404,14 @@ impl Runtime {
.stdin(stdio.input.clone())
.stdout(AsyncStdoutStream::new(8192, stdio.output.clone()))
.stderr(AsyncStdoutStream::new(8192, stdio.error.clone()))
.allow_tcp(false)
.allow_tcp(true)
.allow_udp(false)
.allow_ip_name_lookup(false);
.allow_ip_name_lookup(true);
let policy = self.network.clone();
wasi.socket_addr_check(move |address, _| {
let allowed = policy.allows(address);
Box::pin(async move { allowed })
});
let mut store = Store::new(
&self.engine,
State {
@@ -342,10 +422,21 @@ impl Runtime {
pid,
command,
group,
conversation: conversation.clone(),
conversations: conversations.clone(),
stdio,
preview1,
http: wasmtime_wasi_http::WasiHttpCtx::new(),
hooks: http::Hooks {
caller: crate::cred::Caller {
pid,
program: executable,
conversations: conversations.clone(),
},
cred: self.cred.clone(),
script: self.script.clone(),
policy: self.network.clone(),
router: self.router.clone(),
},
},
);
store.epoch_deadline_async_yield_and_update(1);
@@ -356,7 +447,7 @@ impl Runtime {
argv: request.argv,
cwd: request.cwd,
state: "running".into(),
conversation,
conversation: conversations.first().cloned(),
},
);
let (tx, code) = watch::channel(None);
@@ -459,7 +550,7 @@ impl Host for State {
self.pid,
self.command,
self.group,
self.conversation.clone(),
self.conversations.clone(),
Some(self.stdio.clone()),
)
.await
@@ -613,7 +704,7 @@ impl Exec for Arc<Runtime> {
0,
0,
0,
options.conversation,
options.conversations,
None,
);
let mut child = if let Some(deadline) = deadline {
+85
View File
@@ -0,0 +1,85 @@
//! Host-side network policy. `<dir>/host/network.toml` accepts `deny = ["address:port"]`.
//! Host listeners register their ports with `deny_host_port`. Guests cannot bind or connect there.
use std::{
collections::HashSet,
net::{IpAddr, SocketAddr},
path::Path,
sync::RwLock,
};
fn normalize(address: SocketAddr) -> SocketAddr {
match address {
SocketAddr::V6(v6) => v6
.ip()
.to_ipv4_mapped()
.map(|ip| SocketAddr::new(ip.into(), v6.port()))
.unwrap_or(address),
_ => address,
}
}
pub struct NetPolicy {
denied: HashSet<SocketAddr>,
local: HashSet<IpAddr>,
ports: RwLock<HashSet<u16>>,
}
impl NetPolicy {
pub fn open(host_dir: &Path) -> anyhow::Result<Self> {
#[derive(serde::Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct Config {
#[serde(default)]
deny: Vec<SocketAddr>,
}
let config = match std::fs::read_to_string(host_dir.join("network.toml")) {
Ok(text) => toml::from_str::<Config>(&text)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
Err(e) => return Err(e.into()),
};
Ok(Self {
denied: config.deny.into_iter().map(normalize).collect(),
local: if_addrs::get_if_addrs()?
.into_iter()
.map(|a| a.ip())
.collect(),
ports: RwLock::default(),
})
}
pub fn deny_host_port(&self, port: u16) {
self.ports.write().unwrap().insert(port);
}
pub fn allows(&self, address: SocketAddr) -> bool {
let address = normalize(address);
let ip = address.ip();
!self.denied.contains(&address)
&& !(self.ports.read().unwrap().contains(&address.port())
&& (ip.is_loopback() || ip.is_unspecified() || self.local.contains(&ip)))
}
pub(super) async fn resolve(
&self,
uri: &http::Uri,
) -> wasmtime_wasi_http::Result<Vec<SocketAddr>> {
use wasmtime_wasi_http::Error;
let host = uri
.host()
.ok_or(Error::HttpRequestUriInvalid)?
.trim_matches(['[', ']']);
let port = uri
.port_u16()
.unwrap_or(if uri.scheme_str() == Some("https") {
443
} else {
80
});
let addresses: Vec<_> = tokio::net::lookup_host((host, port))
.await
.map_err(|_| Error::DnsError {
rcode: None,
info_code: None,
})?
.collect();
if addresses.is_empty() || addresses.iter().any(|a| !self.allows(*a)) {
return Err(Error::HttpRequestDenied);
}
Ok(addresses)
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn supervise(runtime: Arc<Runtime>) -> Supervisor {
0,
0,
0,
None,
Vec::new(),
None,
);
let child = tokio::select! { _ = &mut stop => return, child = start => child };
+4 -4
View File
@@ -15,7 +15,7 @@ fn caller() -> Caller {
conversations: vec!["conversation".into()],
}
}
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
let request = http::Request::builder()
.method(method)
.uri(path)
@@ -31,7 +31,7 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u
serde_json::from_slice(response.body())?,
))
}
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result<Value> {
let (status, value) = send(api, method, path, body).await?;
assert_eq!(status, 200, "unexpected API status");
Ok(value)
@@ -55,7 +55,7 @@ async fn public_routes_and_ssh() -> Result<()> {
)?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
Passkeys::new(db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let who = ok(
@@ -221,7 +221,7 @@ async fn ssh_rejects_ambiguous_items() -> Result<()> {
item(&cred, "two")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
Passkeys::new(db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
assert_eq!(
+10 -9
View File
@@ -21,7 +21,7 @@ fn caller() -> Caller {
conversations: vec!["conversation".into()],
}
}
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
let response = handle(
api,
caller(),
@@ -37,12 +37,12 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u
serde_json::from_slice(response.body())?,
))
}
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result<Value> {
let (status, value) = send(api, method, path, body).await?;
assert_eq!(status, 200, "unexpected API status");
Ok(value)
}
async fn page(api: &Api<'_>, path: &str) -> Result<String> {
async fn page(api: &Api, path: &str) -> Result<String> {
let response = handle(
api,
caller(),
@@ -62,7 +62,7 @@ async fn page(api: &Api<'_>, path: &str) -> Result<String> {
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?)
}
async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
async fn file(api: &Api, item: &str, key: &str) -> Result<String> {
let result = ok(
api,
"POST",
@@ -72,7 +72,7 @@ async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
.await?;
Ok(result["request"].as_str().unwrap().into())
}
async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
async fn register(api: &Api) -> Result<WebauthnAuthenticator<SoftPasskey>> {
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(
ok(
api,
@@ -94,7 +94,7 @@ async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
Ok(auth)
}
async fn assertion(
api: &Api<'_>,
api: &Api,
auth: &mut WebauthnAuthenticator<SoftPasskey>,
path: &str,
body: Value,
@@ -114,7 +114,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
let second = item(&cred, "review <script>not markup</script>")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", ORIGIN)?,
Passkeys::new(db, "localhost", ORIGIN)?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
page(&api, "/register").await?;
@@ -314,7 +314,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
Ok(())
}
async fn encrypt(api: &Api<'_>, public_key: Value) -> Result<Envelope> {
async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
use deno_core::{JsRuntime, RuntimeOptions};
let script = handle(
api,
@@ -358,7 +358,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
let item = item(&cred, "secret")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", ORIGIN)?,
Passkeys::new(db, "localhost", ORIGIN)?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let mut auth = register(&api).await?;
@@ -408,6 +408,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
assert!(!api.cred.item_get(&item.id)?.fields[0].needs_value);
ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?;
let master = std::fs::read(dir.path().join("cred.key"))?;
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
let cipher: Vec<u8> = db.query_row(
"SELECT ciphertext FROM fields WHERE item=?",
[&item.id],
+5 -2
View File
@@ -42,7 +42,10 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
.unwrap();
(dir, c, caller, item)
}
fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator<SoftPasskey>) {
fn register(
host: &mut Passkeys<&rusqlite::Connection>,
auth: &mut WebauthnAuthenticator<SoftPasskey>,
) {
let start = host.start_registration("test".into(), None).unwrap();
let response = auth
.do_registration(Url::parse("http://localhost:8000").unwrap(), start.options)
@@ -50,7 +53,7 @@ fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator<SoftPasske
host.finish_registration(&start.id, response).unwrap();
}
fn proof(
host: &mut Passkeys<'_>,
host: &mut Passkeys<&rusqlite::Connection>,
auth: &mut WebauthnAuthenticator<SoftPasskey>,
binding: Binding,
) -> webauthn::Verified {
+1 -1
View File
@@ -16,7 +16,7 @@ impl Exec for FakeExec {
options: Options,
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
) -> anyhow::Result<i32> {
assert!(options.conversation.is_some(), "missing conversation id");
assert!(!options.conversations.is_empty(), "missing conversation id");
let Command::Shell(command) = command else {
anyhow::bail!("expected shell command")
};
+363
View File
@@ -0,0 +1,363 @@
//! 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")
);
}
+102
View File
@@ -0,0 +1,102 @@
//! The hook and native listener share ceremony state without exposing the native listener to guests.
use sausage::{
api::{Api, Listener, Options as ApiOptions},
cred::{Caller, Cred},
exec::{Command, Exec, Options},
process::Runtime,
world::World,
};
use std::{
io::{Read, Write},
sync::Arc,
};
#[path = "hook_guest.rs"]
mod hook_guest;
#[tokio::test]
async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
std::fs::create_dir_all(dir.path().join("world/bin"))?;
std::fs::copy(
hook_guest::component(),
dir.path().join("world/bin/guest.wasm"),
)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let origin = "http://localhost:8081";
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
"localhost",
origin,
)?);
let runtime = Runtime::new_with_hooks(
world,
cred,
sausage::process::default_script(),
Some(api.clone()),
)?;
let listener = Listener::start_with_api(
api.clone(),
ApiOptions {
api_listen: "127.0.0.1:0".parse()?,
api_rp_id: "localhost".into(),
api_origin: origin.into(),
},
)?;
runtime
.network_policy()
.deny_host_port(listener.address().port());
let response = sausage::api::handle(
&api,
Caller {
program: "/bin/guest.wasm".into(),
pid: 1,
conversations: vec![],
},
http::Request::post("http://host.sausage/cred/passkeys/register/start")
.body(bytes::Bytes::from_static(b"{\"label\":\"shared\"}"))?,
)
.await;
assert_eq!(response.status(), 200);
let challenge: serde_json::Value = serde_json::from_slice(response.body())?;
let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true));
let registration = authenticator.do_registration(
url::Url::parse(origin)?,
serde_json::from_value(challenge["options"].clone())?,
)?;
let body = serde_json::to_vec(
&serde_json::json!({"challenge_id":challenge["id"], "response": registration}),
)?;
let mut connection = std::net::TcpStream::connect(listener.address())?;
write!(
connection,
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
body.len()
)?;
connection.write_all(&body)?;
let mut reply = String::new();
connection.read_to_string(&mut reply)?;
assert!(reply.starts_with("HTTP/1.1 200"));
for (uri, expected) in [
("http://host.sausage/cred/passkeys".to_owned(), "shared"),
(format!("http://{}/whoami", listener.address()), "error"),
] {
let mut out = Vec::new();
let code = runtime
.exec(
Command::Argv(vec!["/bin/guest.wasm".into(), uri, "GET".into()]),
Options {
cwd: "/".into(),
timeout: Some(std::time::Duration::from_secs(30)),
..Default::default()
},
&mut |_, b| out.extend_from_slice(b),
)
.await?;
assert_eq!(code, 0);
assert!(String::from_utf8(out)?.contains(expected));
}
Ok(())
}
+208
View File
@@ -0,0 +1,208 @@
//! A real git component pushes an incompressible commit through the hook to native git's HTTP backend.
use sausage::{
exec::{Command, Exec, Options},
process::Runtime,
world::World,
};
use std::{
io::Write,
path::Path,
process::Stdio,
sync::{Arc, OnceLock},
};
fn git(cwd: &Path, args: &[&str]) {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap()
.success()
);
}
#[tokio::test]
async fn several_mb_git_push() {
static BUILT: OnceLock<std::path::PathBuf> = OnceLock::new();
let component = BUILT.get_or_init(|| {
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-git");
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
if !userland.join("cwd-init/init.o").is_file() {
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()
.unwrap()
.success()
);
}
let manifest = userland.join("Cargo.toml");
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(manifest)
.current_dir(&userland)
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(&target)
.args(["-j", "2", "--locked"])
.status()
.unwrap()
.success()
);
target.join("wasm32-wasip2/release/git.wasm")
});
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
let root = temp.path().join("world");
std::fs::create_dir_all(root.join("bin")).unwrap();
std::fs::copy(component, root.join("bin/git.wasm")).unwrap();
let repo = root.join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
let mut data = vec![0; 4 * 1024 * 1024];
openssl::rand::rand_bytes(&mut data).unwrap();
std::fs::write(repo.join("large"), data).unwrap();
git(&repo, &["add", "large"]);
git(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"large commit",
],
);
let remote = temp.path().join("remote");
std::fs::create_dir_all(&remote).unwrap();
git(&remote, &["init", "--bare", "destination.git"]);
git(
&remote.join("destination.git"),
&["config", "http.receivepack", "true"],
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
git(
&repo,
&[
"remote",
"add",
"origin",
&format!("http://127.0.0.1:{port}/destination.git"),
],
);
let uploaded = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = uploaded.clone();
let server = tokio::spawn(async move {
loop {
let (socket, _) = listener.accept().await.unwrap();
let remote = remote.clone();
let counter = counter.clone();
let service =
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
let remote = remote.clone();
let counter = counter.clone();
async move {
use http_body_util::{BodyExt, Full};
let (parts, body) = request.into_parts();
let body = body.collect().await.unwrap().to_bytes();
counter.fetch_add(body.len(), std::sync::atomic::Ordering::Relaxed);
let response = tokio::task::spawn_blocking(move || {
let mut process = std::process::Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", remote)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("REQUEST_METHOD", parts.method.as_str())
.env("PATH_INFO", parts.uri.path())
.env("QUERY_STRING", parts.uri.query().unwrap_or(""))
.env(
"CONTENT_TYPE",
parts
.headers
.get("content-type")
.map(|h| h.to_str().unwrap())
.unwrap_or(""),
)
.env("CONTENT_LENGTH", body.len().to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = process.stdin.take().unwrap();
let output = std::thread::scope(|scope| {
scope.spawn(move || {
stdin.write_all(&body).unwrap();
});
process.wait_with_output().unwrap()
});
assert!(output.status.success());
let split = output
.stdout
.windows(4)
.position(|b| b == b"\r\n\r\n")
.unwrap();
let headers = std::str::from_utf8(&output.stdout[..split]).unwrap();
let mut response = http::Response::builder();
for line in headers.split("\r\n") {
let (name, value) = line.split_once(':').unwrap();
if name.eq_ignore_ascii_case("status") {
response = response.status(
value
.trim()
.split(' ')
.next()
.unwrap()
.parse::<u16>()
.unwrap(),
);
} else {
response = response.header(name, value.trim());
}
}
response
.body(Full::new(bytes::Bytes::copy_from_slice(
&output.stdout[split + 4..],
)))
.unwrap()
})
.await
.unwrap();
Ok::<_, std::convert::Infallible>(response)
}
});
hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.serve_connection(hyper_util::rt::TokioIo::new(socket), service)
.await
.unwrap();
}
});
let runtime = Runtime::new(world).unwrap();
let mut output = Vec::new();
let code = runtime
.exec(
Command::Argv(vec![
"/bin/git.wasm".into(),
"push".into(),
"origin".into(),
"main".into(),
]),
Options {
cwd: "/repo".into(),
timeout: Some(std::time::Duration::from_secs(60)),
..Default::default()
},
&mut |_, b| output.extend_from_slice(b),
)
.await
.unwrap();
server.abort();
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output));
assert!(uploaded.load(std::sync::atomic::Ordering::Relaxed) > 3 * 1024 * 1024);
git(
&temp.path().join("remote/destination.git"),
&["cat-file", "-e", "refs/heads/main:large"],
);
}
+73
View File
@@ -0,0 +1,73 @@
//! Persistent test component shared by HTTP and socket acceptance tests.
use std::{
path::{Path, PathBuf},
sync::OnceLock,
};
fn write(path: PathBuf, text: &str) -> std::io::Result<()> {
if std::fs::read_to_string(&path).ok().as_deref() != Some(text) {
std::fs::write(path, text)?;
}
Ok(())
}
pub fn component() -> &'static Path {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest");
std::fs::create_dir_all(directory.join("src")).unwrap();
std::fs::create_dir_all(directory.join("wit/deps")).unwrap();
write(directory.join("wit/process.wit"), include_str!("../wit/process.wit")).unwrap();
write(directory.join("wit/deps/io.wit"), include_str!("../wit/deps/io.wit")).unwrap();
write(directory.join("Cargo.toml"), "[package]\nname='hook-guest'\nversion='0.1.0'\nedition='2024'\n[workspace]\n[dependencies]\nwstd='=0.6.8'\nanyhow='1'\nfutures-lite='2'\nwit-bindgen='0.46'\n").unwrap();
write(directory.join("src/main.rs"), r#"
use wstd::http::{Body, BodyExt, Client, Request};
mod bindings { wit_bindgen::generate!({path:"wit",world:"program",generate_all}); }
#[wstd::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<_> = std::env::args().collect();
match args[1].as_str() {
"follow" => {
let response=Client::new().send(Request::get(&args[2]).body(Body::empty())?).await?;
println!("first-status={}",response.status().as_u16());
let location=response.headers()["location"].to_str()?.to_owned();
response.into_body().into_boxed_body().collect().await?;
let response=Client::new().send(Request::get(location).body(Body::empty())?).await?;
println!("status={}",response.status().as_u16());
response.into_body().into_boxed_body().collect().await?;
},
"spawn" => {
use bindings::sausage::process::host::{self, Request, Stdio};
let child=host::spawn(&Request {program:"/bin/guest.wasm".into(),argv:vec!["guest".into(),args[2].clone(),"GET".into()],cwd:"/".into(),env:vec![],group:None,stdin:Stdio::Null,stdout:Stdio::Inherit,stderr:Stdio::Inherit}).unwrap();
println!("child={}",child.pid());
assert_eq!(child.wait(),0);
},
"listen" => {
let listener = std::net::TcpListener::bind(&args[2])?;
println!("ready");
let (mut stream, _) = listener.accept()?;
use std::io::{Read, Write};
let mut bytes = [0;4]; stream.read_exact(&mut bytes)?;
stream.write_all(&bytes)?;
},
"connect" => {
use std::io::{Read, Write};
match std::net::TcpStream::connect(&args[2]) {
Ok(mut stream) => { stream.write_all(b"ping")?; let mut bytes=[0;4]; stream.read_exact(&mut bytes)?; println!("{}", std::str::from_utf8(&bytes)?); },
Err(_) => println!("denied"),
}
},
_ => {
let body = if args.len() > 3 { Body::from_stream(futures_lite::stream::iter((0..args[3].parse::<usize>()?/8192).map(|_| vec![b'x';8192]))) } else { Body::empty() };
let request = Request::builder().uri(&args[1]).method(args[2].as_str()).body(body)?;
match Client::new().send(request).await {
Ok(response) => { println!("status={}", response.status().as_u16()); let bytes=response.into_body().into_boxed_body().collect().await?.to_bytes(); println!("{}",String::from_utf8_lossy(&bytes)); },
Err(_) => println!("error"),
}
}
}
Ok(())
}
"#).unwrap();
assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", directory.join("target")).status().unwrap().success(), "test guest build failed");
directory.join("target/wasm32-wasip2/release/hook-guest.wasm")
})
}
+125
View File
@@ -0,0 +1,125 @@
//! Real components can accept TCP connections. Host port registration and host-only config deny access.
mod hook_guest;
use sausage::{
exec::{Command, Exec, Options},
process::{Runtime, net::NetPolicy},
world::World,
};
use std::{sync::Arc, time::Duration};
#[test]
fn host_policy() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("network.toml"),
"deny = ['127.0.0.1:9001', '[::1]:9002']",
)
.unwrap();
let policy = NetPolicy::open(temp.path()).unwrap();
policy.deny_host_port(9000);
for address in [
"127.0.0.1:9000",
"0.0.0.0:9000",
"[::1]:9000",
"[::ffff:127.0.0.1]:9000",
"127.0.0.1:9001",
"[::1]:9002",
] {
assert!(!policy.allows(address.parse().unwrap()));
}
assert!(policy.allows("0.0.0.0:8080".parse().unwrap()));
for interface in if_addrs::get_if_addrs().unwrap() {
assert!(!policy.allows(std::net::SocketAddr::new(interface.ip(), 9000)));
}
assert!(!policy.allows("[::ffff:127.0.0.1]:9001".parse().unwrap()));
}
async fn run(runtime: &Arc<Runtime>, mode: &str, address: String) -> String {
let mut output = Vec::new();
let code = runtime
.exec(
Command::Argv(vec!["/bin/guest.wasm".into(), mode.into(), address]),
Options {
cwd: "/".into(),
timeout: Some(Duration::from_secs(30)),
..Default::default()
},
&mut |_, bytes| output.extend_from_slice(bytes),
)
.await
.unwrap();
assert_eq!(code, 0);
String::from_utf8(output).unwrap()
}
#[tokio::test]
async fn guest_tcp_and_host_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 configured = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
std::fs::write(
world.host_dir.join("network.toml"),
format!("deny = ['{}']", configured.local_addr().unwrap()),
)
.unwrap();
let runtime = Runtime::new(world).unwrap();
assert!(
run(
&runtime,
"connect",
configured.local_addr().unwrap().to_string()
)
.await
.contains("denied")
);
let native = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let denied = native.local_addr().unwrap();
runtime.network.deny_host_port(denied.port());
assert!(
run(&runtime, "connect", denied.to_string())
.await
.contains("denied")
);
let ephemeral = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = ephemeral.local_addr().unwrap();
drop(ephemeral);
let server_runtime = runtime.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let mut tx = Some(tx);
let mut bytes = Vec::new();
server_runtime
.exec(
Command::Argv(vec![
"/bin/guest.wasm".into(),
"listen".into(),
format!("0.0.0.0:{}", address.port()),
]),
Options {
cwd: "/".into(),
timeout: Some(Duration::from_secs(30)),
..Default::default()
},
&mut |_, chunk| {
bytes.extend_from_slice(chunk);
if bytes.windows(5).any(|w| w == b"ready")
&& let Some(tx) = tx.take()
{
let _ = tx.send(());
}
},
)
.await
.unwrap()
});
tokio::time::timeout(Duration::from_secs(30), rx)
.await
.unwrap()
.unwrap();
assert!(
run(&runtime, "connect", format!("localhost:{}", address.port()))
.await
.contains("ping")
);
assert_eq!(server.await.unwrap(), 0);
}
+1 -1
View File
@@ -264,7 +264,7 @@ mod features {
exec.exec(
Command::Shell("sleep 60 | cat".into()),
Options {
conversation: Some("c1".into()),
conversations: vec!["c1".into()],
..Default::default()
},
&mut |_, _| {},