diff --git a/CLAUDE.md b/CLAUDE.md index fb46f07..59446f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,8 @@ as a definition does. | `setup.rs` | `sudo porta setup`: a copy in a directory only root can write (`/usr/local/bin`, else `/usr/libexec/porta` linked from it) and the AppArmor profile that gives that copy user namespaces | | `snapshot.rs` | `--snapshot`: the writable mounts copied before a run to `~/.porta/snapshots`, what the run changed, and `porta rollback` | | `recipes.rs` + `recipes/*.toml` | `porta init claude` / `codex`: a `porta.toml` measured to what each agent needs | -| `http_proxy.rs`, `proxy_audit.rs` | the loopback CONNECT proxy and its decision trail | +| `http_proxy.rs`, `proxy_egress.rs`, `proxy_audit.rs` | the loopback CONNECT proxy, its run credential and address guard, and its decision trail | +| `credential_broker.rs` | credentials as placeholders: the run's CA, TLS terminated for bound hosts only, the real value put on where it is bound and refused elsewhere | | `http_client.rs`, `host_process.rs`, `wasm_inspect.rs` | checked host services: one HTTP request, process helpers, module inspection | | `agent_runtime.rs` + `agent_runtime/` | the broker: `loading` (config, pins, team), `guest`, `model`, `verification`, `tools`, `inspect`, `ffi` | | `agent_journal.rs` | durable broker records, exclusive access, replay validation | @@ -136,6 +137,9 @@ accessors use `value.*`; serialization and typed key lookups use `json.*`. - Proxy mode permits only the loopback proxy endpoint, without UDP, Unix sockets or any other egress channel — including a ring that would open a socket without `socket(2)`. +- A credential's value never enters the child's environment, never reaches a + log, and is put on only for a host and path it is bound to; a host no + credential names is tunnelled, never intercepted. - MCP stdio uses newline-delimited JSON; diagnostics belong on stderr. - MCP result adaptation must preserve multi-field payloads and error status; only legacy single-key Result envelopes unwrap. - Agent and tool instances inherit no host environment or implicit directories. diff --git a/README.md b/README.md index ed5f321..2f4c604 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ Linux ([overhead](docs/benchmarks/overhead.md)). ([agent runtime](docs/agent-runtime.md)). - **One API and a log.** `--proxy-allow api.example.com --proxy-audit egress.jsonl` lets through only that host and records every decision. +- **Keys the command cannot read.** `--credential ANTHROPIC_API_KEY=api.anthropic.com` + hands the command a placeholder; porta's proxy puts the real key on requests + to that host alone, and refuses the placeholder anywhere else it looks + ([enforcement](docs/enforcement.md#credentials-as-placeholders)). ## Docs diff --git a/almide.toml b/almide.toml index 592520f..baaacac 100644 --- a/almide.toml +++ b/almide.toml @@ -17,3 +17,9 @@ libc = "0.2" regex = "1" toml = "0.8" +# The credential broker (#37): TLS terminated for bound hosts with a CA made +# per run, and re-opened to the real server. ring, the provider reqwest's +# rustls already builds, so no second crypto library and no OpenSSL. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring", "pem"] } +webpki-roots = "1" diff --git a/docs/cli.md b/docs/cli.md index fed1957..3781f53 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -64,6 +64,7 @@ line, never dropped. | `--proxy-allow ` | Route egress through porta's CONNECT proxy and allow only these hosts | | `--proxy-deny ` | Same, denying these hosts | | `--proxy-audit ` | Append every proxy decision to a JSONL file | +| `--credential ` | Hand host variable NAME in as a placeholder; the proxy puts the real value on requests to `host[:443][/path]` only (repeatable) | | `--read-policy ` | `strict` confines reads to your mounts and the system directories (default `open`) | | `--allow-root` | Run as root anyway. Refused by default: for root, the file permissions this policy leans on separate nothing | | `--env-pass ` | Copy these host variables into the command. The child starts from an empty environment plus `PATH`, `HOME`, `USER`, `SHELL`, `TERM` and the locale; nothing else of your shell crosses unless `-e` or this names it | @@ -124,6 +125,11 @@ network = ["*:443"] # Restrict to these ports (empty = all open) # allow = ["api.example.com"] # egress only through the CONNECT proxy, these hosts # audit = "egress.jsonl" +# [[credentials]] # a placeholder inside, the real value put on by the proxy +# name = "ANTHROPIC_API_KEY" +# from = { env = "ANTHROPIC_API_KEY" } +# hosts = ["api.anthropic.com/v1"] + [env] NODE_ENV = "production" diff --git a/docs/enforcement.md b/docs/enforcement.md index e408746..3178b32 100644 --- a/docs/enforcement.md +++ b/docs/enforcement.md @@ -86,8 +86,50 @@ allow-list. An allowed name that resolves to loopback, a link-local or cloud metadata address, or a multicast group is refused: a hostname on the list is a promise about a public service, not a route to this machine. Private ranges stay reachable. Every decision is written and synced to the audit file before -the connection proceeds. Deny lists are weaker than explicit allow lists, and -this is not a credential broker. +the connection proceeds. Deny lists are weaker than explicit allow lists. + +## Credentials as placeholders + +```bash +porta run claude -v . --credential ANTHROPIC_API_KEY=api.anthropic.com +``` + +Or in `porta.toml`: + +```toml +[[credentials]] +name = "ANTHROPIC_API_KEY" # the variable the command sees +from = { env = "ANTHROPIC_API_KEY" } # where porta reads the real value (default: the name) +hosts = ["api.anthropic.com/v1"] # host[:443][/path], *.wildcard allowed +``` + +The command's `ANTHROPIC_API_KEY` is `porta-cred-ANTHROPIC_API_KEY-`, +minted for this run. The real value stays in porta. A credential turns the +proxy on (with no `--proxy-allow`, every host is reachable through it; with one, +the bound hosts are added to it), and for a bound host porta terminates the +command's TLS with a certificate from a CA made for this run, replaces the +placeholder wherever it appears in the request line, query or headers +(`Authorization: Bearer …`, `x-api-key: …` alike), and sends the request on +over its own TLS connection, which verifies the real server against the +webpki roots. The same placeholder in a request to a bound host on a path its +credential is not bound to is refused with 403 before anything is sent. Every +substitution and refusal goes to stderr and the audit file with the +credential's name, never its value. + +The command is pointed at the run's CA with `SSL_CERT_FILE`, +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` (a bundle of the +system's roots plus the CA, since those replace the trust store) and +`NODE_EXTRA_CA_CERTS` (the CA alone, since Node adds it). The CA is valid for +a week, lives in memory, and its certificate and the bundle are removed when +the run ends. A client that ignores all of these (Go on macOS, which asks the +Keychain) will not trust the intercepted connection, and fails closed. + +Only bound hosts are intercepted. Every other CONNECT is tunnelled as before, +end to end and unread; a placeholder sent there is only a random string. Each +intercepted connection carries one request (porta asks the server for +`Connection: close`), and speaks HTTP/1.1 only; request bodies are passed on, +not inspected. Values come from porta's environment for now; a file, the +Keychain or a command as the source are left for later. ## WASM sandbox diff --git a/docs/threat-model.md b/docs/threat-model.md index e85c36c..2b8265a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -51,6 +51,7 @@ true. | Proxy mode: the loopback proxy is the only egress | Landlock TCP port rule + seccomp socket-family filter; Seatbelt profile | integration "proxy mode is the only egress" | | The proxy serves only this run's client, and never tunnels to loopback/metadata addresses | per-run credential; resolved-address guard | integration "CONNECT needs the run credential" | | Syscalls that reach around a file policy are refused (Linux, every mode) | seccomp baseline: `ptrace`, `process_vm_*`, mounts, namespaces, `io_uring`, `execveat(AT_EMPTY_PATH)`, … | `escapes.py` "ptrace", "fileless exec", "io_uring", "new user namespace" | +| A credential given with `--credential` / `[[credentials]]` is never in the child's environment, and its value goes only on requests to the hosts and paths it is bound to; a placeholder on any other path of an intercepted host is refused and recorded | per-run placeholder; the proxy terminates TLS for bound hosts only, with a per-run CA, and verifies the real server | integration "a credential is a placeholder inside" | | The child inherits no host environment beyond `PATH`, `HOME`, locale, terminal | `env_clear` then a named allow-list | integration "the host environment stays outside" | | A policy the platform cannot express refuses the run | `prepare` returns an error; `run` exits 125 | integration "unexpressible rule refused" | @@ -59,6 +60,10 @@ true. These are out of scope by design, not undiscovered. A report that porta does not stop one of them is accurate and not a vulnerability. +- **Use of a credential where it is bound.** A credential handed in as a + placeholder cannot be read or sent anywhere else, but the command can still + make any request it likes to the host the credential is bound to, and porta + will put the key on it. Bind to the narrowest path that does the job. - **Kernel exploits.** porta's boundary is the kernel's. A privilege escalation through a kernel bug defeats it — and defeats a container on the same kernel. Those belong upstream. diff --git a/native/credential_broker.rs b/native/credential_broker.rs new file mode 100644 index 0000000..e5a7144 --- /dev/null +++ b/native/credential_broker.rs @@ -0,0 +1,225 @@ +//! Credentials handed in as placeholders, and put on in the proxy (#37). +//! +//! A command confined by porta that needs an API key used to get the key +//! itself, in its environment, where every process it starts can read it and +//! send it anywhere its network allows. Here it gets a placeholder instead: +//! `porta-cred-NAME-`, worth nothing outside this run. The real value +//! stays in porta. When the command's HTTPS request to a host the credential +//! is bound to comes through the proxy, porta terminates that TLS connection +//! with a certificate from a CA made for this run, puts the real value where +//! the placeholder is, and sends the request on over a TLS connection of its +//! own that verifies the real server. The same placeholder on its way to +//! anywhere else among the intercepted hosts is refused, and recorded. +//! +//! Only bound hosts are intercepted. Every other CONNECT is tunnelled exactly +//! as before, encrypted end to end and not read: a placeholder sent there is +//! only a random string. +//! +//! This file holds the credentials and the rule for where each may go; `tls` +//! makes the run's CA and the certificates it signs; `exchange` carries one +//! intercepted request across. + +use crate::proxy_egress::mint_token; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +mod exchange; +mod tls; + +/// Where one credential may go: a host pattern (`*.example.com` allowed), the +/// port, and a path prefix ("" for any path). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Binding { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) path: String, +} + +/// One credential: the name the command sees it under, the real value, the +/// placeholder the command is given instead, and where it may be put on. +pub(crate) struct Credential { + pub(crate) name: String, + value: String, + pub(crate) placeholder: String, + pub(crate) bindings: Vec, +} + +/// The host and port a CONNECT asked for. +#[derive(Clone, Copy)] +pub(crate) struct Target<'a> { + pub(crate) host: &'a str, + pub(crate) port: u16, +} + +/// `host[:port][/path]` as `--credential` and `[[credentials]]` write it. The +/// proxy tunnels port 443 alone, so a binding to another port could never be +/// used, and is refused here rather than silently doing nothing. +pub(crate) fn parse_binding(text: &str) -> Result { + let text = text.trim(); + let (authority, path) = match text.find('/') { + Some(slash) => (&text[..slash], &text[slash..]), + None => (text, ""), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => (host, port.parse::().map_err(|_| format!("credential binding {text}: the port is not a number"))?), + None => (authority, 443), + }; + if host.is_empty() { + return Err(format!("credential binding {text}: no host")); + } + if port != 443 { + return Err(format!("credential binding {text}: the proxy carries HTTPS on port 443 only")); + } + Ok(Binding { host: host.to_ascii_lowercase(), port, path: path.trim_end_matches('/').to_string() }) +} + +impl Binding { + fn reaches(&self, at: Target) -> bool { + crate::http_proxy::host_matches(at.host, &self.host) && self.port == at.port + } + + fn covers(&self, at: Target, request_target: &str) -> bool { + self.reaches(at) && path_under(request_target, &self.path) + } +} + +/// Whether a request target (query and all) is `prefix` or below it: `/v1` +/// covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`. +fn path_under(request_target: &str, prefix: &str) -> bool { + if prefix.is_empty() { + return true; + } + let path = request_target.split(['?', '#']).next().unwrap_or(""); + path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/')) +} + +/// What the request looks like once the placeholders in it have been dealt +/// with: every bound one replaced, or the first one that may not go here. +#[derive(Debug, PartialEq)] +pub(crate) enum Rewrite { + /// The head to send on, and the names of the credentials put on. + Send(String, Vec), + /// A placeholder for this credential is in a request it is not bound to. + Refuse(String), +} + +/// Replaces each credential's placeholder in the request head (request line, +/// query and headers alike) with its value, where the credential is bound to +/// this host, port and path; refuses the request when a placeholder is there +/// and it is not. +pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, at: Target, request_target: &str) -> Rewrite { + let mut out = head.to_string(); + let mut used = Vec::new(); + for credential in credentials.iter().filter(|c| head.contains(&c.placeholder)) { + if !credential.bindings.iter().any(|binding| binding.covers(at, request_target)) { + return Rewrite::Refuse(credential.name.clone()); + } + out = out.replace(&credential.placeholder, &credential.value); + used.push(credential.name.clone()); + } + Rewrite::Send(out, used) +} + +/// The run's credentials, its CA, and the leaf certificates signed so far. +pub(crate) struct Broker { + pub(crate) credentials: Vec, + ca: tls::Authority, + leaves: Mutex>>, + upstream: Arc, + /// The directory holding the CA certificate and the trust bundle, removed + /// when the proxy stops. + pub(crate) dir: String, + ca_path: String, + bundle_path: Option, +} + +/// One credential as the caller hands it over: the value read from where the +/// configuration said, and the bindings as written. +#[derive(serde::Deserialize)] +pub(crate) struct Requested { + name: String, + value: String, + hosts: Vec, +} + +/// The broker a `[{"name","value","hosts"}]` list asks for, or None for an +/// empty one: a run with no credentials tunnels everything, as before. +pub(crate) fn broker_from_json(credentials_json: &str) -> Result>, String> { + let requested: Vec = serde_json::from_str(credentials_json).map_err(|e| format!("credentials: {e}"))?; + if requested.is_empty() { + return Ok(None); + } + Broker::new(requested).map(|broker| Some(Arc::new(broker))) +} + +impl Broker { + /// A broker for these credentials: placeholders minted, the run's CA made + /// and written out with a trust bundle the command is pointed at. + fn new(requested: Vec) -> Result { + let credentials = merged(requested)?; + let ca = tls::Authority::new()?; + let dir = format!("/tmp/porta-credentials-{}", mint_token()); + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {dir}: {e}"))?; + let (ca_path, bundle_path) = ca.write_trust(&dir)?; + Ok(Broker { credentials, ca, leaves: Mutex::new(HashMap::new()), upstream: tls::upstream_config()?, dir, ca_path, bundle_path }) + } + + /// Whether any credential is bound to this host and port, so its + /// connections are opened rather than tunnelled. + pub(crate) fn intercepts(&self, at: Target) -> bool { + self.credentials.iter().any(|c| c.bindings.iter().any(|b| b.reaches(at))) + } + + /// The hosts to add to an allow-list: a credential bound to a host the + /// list does not reach would be handed in for nothing. + pub(crate) fn hosts(&self) -> Vec { + self.credentials.iter().flat_map(|c| c.bindings.iter().map(|b| b.host.clone())).collect() + } + + /// What the command's environment gains: each credential's placeholder + /// under its name, and the variables that make its TLS clients trust the + /// run's CA. + pub(crate) fn child_env(&self) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = self.credentials.iter().map(|c| (c.name.clone(), c.placeholder.clone())).collect(); + // Node adds this file to its own roots; it is the one client here that + // takes an addition rather than a replacement. + env.push(("NODE_EXTRA_CA_CERTS".into(), self.ca_path.clone())); + if let Some(bundle) = &self.bundle_path { + for name in ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO"] { + env.push((name.into(), bundle.clone())); + } + } + env + } + + fn leaf_for(&self, host: &str) -> Result, String> { + let mut leaves = crate::locking::locked(&self.leaves); + if let Some(config) = leaves.get(host) { + return Ok(config.clone()); + } + let config = Arc::new(self.ca.server_config_for(host)?); + leaves.insert(host.to_string(), config.clone()); + Ok(config) + } +} + +/// Credentials by name: two bindings given for one name are one credential +/// with one placeholder. A name with no value is refused rather than handed +/// in as an empty key. +fn merged(requested: Vec) -> Result, String> { + let mut out: Vec = Vec::new(); + for r in requested { + if r.value.is_empty() { + return Err(format!("credential {}: no value to hand over (is it set in porta's environment?)", r.name)); + } + let bindings = r.hosts.iter().map(|h| parse_binding(h)).collect::, _>>()?; + match out.iter_mut().find(|c| c.name == r.name) { + Some(existing) => existing.bindings.extend(bindings), + None => out.push(Credential { placeholder: format!("porta-cred-{}-{}", r.name, mint_token()), name: r.name, value: r.value, bindings }), + } + } + Ok(out) +} + +#[cfg(test)] +mod tests; diff --git a/native/credential_broker/exchange.rs b/native/credential_broker/exchange.rs new file mode 100644 index 0000000..38aa236 --- /dev/null +++ b/native/credential_broker/exchange.rs @@ -0,0 +1,211 @@ +//! One intercepted request, carried across: the command's TLS answered with +//! the run's certificate for the host, the head read and rewritten, the +//! request sent on over porta's own TLS to the real server, and the response +//! carried back. +//! +//! Each intercepted connection carries one request: porta asks the server for +//! `Connection: close` and closes the client's side after the response, so a +//! client sends its next request on a fresh connection. That costs a +//! handshake per request to a bound host and buys a proxy that never has to +//! find where one response ends and the next begins. + +use super::{rewrite_head, Broker, Rewrite, Target}; +use crate::proxy_audit::{audit_log, Decision}; +use crate::proxy_egress::dial; +use rustls::pki_types::ServerName; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +type ClientSide = BufReader>; + +impl Broker { + /// Takes over a CONNECT to a bound host: answers it, speaks TLS to the + /// command as the host, and carries one request on to the real server + /// with the credential put on. + pub(crate) fn intercept(&self, mut client: TcpStream, audit: &Option, at: Target) { + let config = match self.leaf_for(at.host) { + Ok(config) => config, + Err(reason) => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); + return audit_log(audit, &Decision::new(at.host, at.port, "error", reason)); + } + }; + let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); + let Ok(connection) = rustls::ServerConnection::new(config) else { return }; + let _ = client.set_read_timeout(Some(Duration::from_secs(120))); + let mut reader = BufReader::new(rustls::StreamOwned::new(connection, client)); + if let Some((request_line, headers)) = read_head(&mut reader) { + self.answer(&mut reader, audit, at, &format!("{}\r\n{}\r\n", request_line, headers.join("\r\n"))); + } + close(reader.get_mut()); + } + + /// The request refused, or sent on with its credentials put on, and the + /// decision recorded either way. + fn answer(&self, reader: &mut ClientSide, audit: &Option, at: Target, head: &str) { + let request_target = head.split_whitespace().nth(1).unwrap_or("/").to_string(); + let path = request_target.split(['?', '#']).next().unwrap_or("/").to_string(); + let record = |verdict: &'static str, reason: String| audit_log(audit, &Decision::new(at.host, at.port, verdict, reason)); + match rewrite_head(&self.credentials, head, at, &request_target) { + Rewrite::Refuse(name) => { + let _ = reader.get_mut().write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + record("deny", format!("credential {name} is not bound to {}{path}: the request was not sent", at.host)); + } + Rewrite::Send(head, used) => { + if used.is_empty() { + record("allow", format!("opened for its credentials; {path} carried none")); + } + for name in &used { + record("substitute", format!("credential {name} put on for {path}")); + } + if let Err(reason) = self.forward(reader, &head, at) { + let _ = reader.get_mut().write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + record("error", reason); + } + } + } + } + + /// Sends the rewritten head and the body after it to the real server, then + /// the whole response back. + fn forward(&self, client: &mut ClientSide, head: &str, at: Target) -> Result<(), String> { + let upstream = dial(at.host, at.port).map_err(|failure| match failure { + crate::proxy_egress::Dial::Blocked(reason) | crate::proxy_egress::Dial::Failed(reason) => reason, + })?; + let _ = upstream.set_read_timeout(Some(Duration::from_secs(300))); + let name = ServerName::try_from(at.host.to_string()).map_err(|e| format!("{}: {e}", at.host))?; + let connection = rustls::ClientConnection::new(self.upstream.clone(), name).map_err(|e| format!("upstream TLS: {e}"))?; + let mut server = rustls::StreamOwned::new(connection, upstream); + let (lines, framing) = outgoing_head(head); + server.write_all(lines.as_bytes()).map_err(|e| format!("upstream TLS: {e}"))?; + copy_body(client, &mut server, framing).map_err(|e| format!("request body: {e}"))?; + server.flush().map_err(|e| format!("upstream: {e}"))?; + copy_response(&mut server, client.get_mut()) + } +} + +/// The response, to the end of the server's stream. +fn copy_response(server: &mut R, client: &mut W) -> Result<(), String> { + let mut buffer = [0u8; 16384]; + loop { + match server.read(&mut buffer) { + Ok(0) => return Ok(()), + Ok(n) => client.write_all(&buffer[..n]).map_err(|e| format!("client: {e}"))?, + // A server that closes without close_notify has still sent the + // whole response: it was asked for `Connection: close`. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(format!("upstream read: {e}")), + } + } +} + +/// How the request's body is framed, from its headers. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(crate) enum Framing { + None, + Length(u64), + Chunked, +} + +/// The head as it goes to the server: hop-by-hop headers dropped, `Expect` +/// dropped (the body follows at once; a client waiting on 100 Continue sends +/// it after its own short wait), and `Connection: close` asked for. +pub(crate) fn outgoing_head(head: &str) -> (String, Framing) { + let mut lines = head.split("\r\n").filter(|l| !l.is_empty()); + let mut out = format!("{}\r\n", lines.next().unwrap_or("")); + let mut framing = Framing::None; + for line in lines { + let Some((name, value)) = line.split_once(':') else { continue }; + let name = name.trim().to_ascii_lowercase(); + if matches!(name.as_str(), "connection" | "proxy-connection" | "keep-alive" | "expect" | "proxy-authorization") { + continue; + } + framing = framing_after(framing, &name, value); + out.push_str(line); + out.push_str("\r\n"); + } + out.push_str("Connection: close\r\n\r\n"); + (out, framing) +} + +/// Chunked wins over a length, as HTTP/1.1 says it must. +fn framing_after(framing: Framing, name: &str, value: &str) -> Framing { + match name { + "transfer-encoding" if value.to_ascii_lowercase().contains("chunked") => Framing::Chunked, + "content-length" if framing != Framing::Chunked => Framing::Length(value.trim().parse().unwrap_or(0)), + _ => framing, + } +} + +fn copy_body(from: &mut R, to: &mut W, framing: Framing) -> std::io::Result<()> { + match framing { + Framing::None => Ok(()), + Framing::Length(n) => { + let copied = std::io::copy(&mut from.take(n), to)?; + if copied < n { Err(ended_early("body shorter than Content-Length")) } else { Ok(()) } + } + Framing::Chunked => copy_chunks(from, to), + } +} + +fn ended_early(what: &str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, what.to_string()) +} + +/// Chunks as they come, through the last one and its trailers. +fn copy_chunks(from: &mut R, to: &mut W) -> std::io::Result<()> { + loop { + let mut size_line = String::new(); + if from.read_line(&mut size_line)? == 0 { + return Err(ended_early("chunked body ended early")); + } + to.write_all(size_line.as_bytes())?; + let size = u64::from_str_radix(size_line.trim().split(';').next().unwrap_or("0").trim(), 16).unwrap_or(0); + if size == 0 { + return copy_trailers(from, to); + } + std::io::copy(&mut from.take(size + 2), to)?; + } +} + +/// Trailers, then the blank line that ends them. +fn copy_trailers(from: &mut R, to: &mut W) -> std::io::Result<()> { + loop { + let mut line = String::new(); + if from.read_line(&mut line)? == 0 { + return Ok(()); + } + to.write_all(line.as_bytes())?; + if line == "\r\n" || line == "\n" { + return Ok(()); + } + } +} + +/// The request line and headers, up to 64 KiB of them. +fn read_head(reader: &mut R) -> Option<(String, Vec)> { + let mut request_line = String::new(); + if reader.read_line(&mut request_line).ok()? == 0 { + return None; + } + let mut headers = Vec::new(); + let mut total = request_line.len(); + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).ok().filter(|n| *n > 0)?; + if line == "\r\n" || line == "\n" { + return Some((request_line.trim_end().to_string(), headers)); + } + total += n; + if total > 65536 { + return None; + } + headers.push(line.trim_end().to_string()); + } +} + +fn close(stream: &mut rustls::StreamOwned) { + stream.conn.send_close_notify(); + let _ = stream.flush(); +} diff --git a/native/credential_broker/tests.rs b/native/credential_broker/tests.rs new file mode 100644 index 0000000..b123945 --- /dev/null +++ b/native/credential_broker/tests.rs @@ -0,0 +1,63 @@ +use super::exchange::{outgoing_head, Framing}; +use super::tls::civil_from_days; +use super::*; + +fn credential(name: &str, value: &str, hosts: &[&str]) -> Credential { + Credential { + name: name.into(), + value: value.into(), + placeholder: format!("porta-cred-{name}-0123"), + bindings: hosts.iter().map(|h| parse_binding(h).unwrap()).collect(), + } +} + +fn at(host: &str) -> Target<'_> { + Target { host, port: 443 } +} + +#[test] +fn bindings_parse_and_refuse_other_ports() { + assert_eq!(parse_binding("API.example.com").unwrap(), Binding { host: "api.example.com".into(), port: 443, path: "".into() }); + assert_eq!(parse_binding("api.example.com:443/v1/").unwrap().path, "/v1"); + assert!(parse_binding("api.example.com:8443").is_err()); + assert!(parse_binding(":443").is_err()); +} + +#[test] +fn a_placeholder_is_put_on_only_where_it_is_bound() { + let creds = vec![credential("KEY", "real-secret", &["api.example.com/v1"])]; + let head = "GET /v1/models?k=porta-cred-KEY-0123 HTTP/1.1\r\nx-api-key: porta-cred-KEY-0123\r\n\r\n"; + match rewrite_head(&creds, head, at("api.example.com"), "/v1/models?k=porta-cred-KEY-0123") { + Rewrite::Send(out, used) => { + assert!(!out.contains("porta-cred-KEY")); + assert_eq!(out.matches("real-secret").count(), 2); + assert_eq!(used, vec!["KEY".to_string()]); + } + other => panic!("{other:?}"), + } + assert_eq!(rewrite_head(&creds, head, at("api.example.com"), "/admin"), Rewrite::Refuse("KEY".into())); + assert_eq!(rewrite_head(&creds, head, at("evil.example.com"), "/v1/models"), Rewrite::Refuse("KEY".into())); + assert!(!path_under("/v10", "/v1")); +} + +#[test] +fn a_request_without_a_placeholder_goes_as_it_is() { + let creds = vec![credential("KEY", "real-secret", &["api.example.com"])]; + let head = "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n"; + assert_eq!(rewrite_head(&creds, head, at("api.example.com"), "/"), Rewrite::Send(head.into(), vec![])); +} + +#[test] +fn the_outgoing_head_asks_for_close_and_knows_its_body() { + let (head, framing) = outgoing_head("POST /v1 HTTP/1.1\r\nHost: a\r\nConnection: keep-alive\r\nExpect: 100-continue\r\nContent-Length: 12\r\n\r\n"); + assert!(head.ends_with("Connection: close\r\n\r\n") && !head.contains("keep-alive") && !head.contains("Expect")); + assert_eq!(framing, Framing::Length(12)); + assert_eq!(outgoing_head("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n").1, Framing::Chunked); +} + +#[test] +fn days_become_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_724), (2026, 9, 28)); + assert_eq!(civil_from_days(11_016), (2000, 2, 29)); +} diff --git a/native/credential_broker/tls.rs b/native/credential_broker/tls.rs new file mode 100644 index 0000000..202ec42 --- /dev/null +++ b/native/credential_broker/tls.rs @@ -0,0 +1,142 @@ +//! The run's CA, the certificates it signs for bound hosts, the trust bundle +//! the command is pointed at, and how porta verifies the real server. +//! +//! Everything runs on `ring`, the provider reqwest's rustls already builds: +//! no second crypto library, and no OpenSSL. + +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) +} + +/// A CA made for one run. It lives in memory; its certificate alone is +/// written out, for the command to trust. +pub(super) struct Authority { + cert: rcgen::Certificate, + key: rcgen::KeyPair, +} + +impl Authority { + pub(super) fn new() -> Result { + let mut params = rcgen::CertificateParams::default(); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.distinguished_name.push(rcgen::DnType::CommonName, "porta run CA (this run only)"); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign, rcgen::KeyUsagePurpose::DigitalSignature]; + set_validity(&mut params); + let key = rcgen::KeyPair::generate().map_err(|e| format!("CA key: {e}"))?; + let cert = params.self_signed(&key).map_err(|e| format!("CA: {e}"))?; + Ok(Authority { cert, key }) + } + + /// Writes the CA certificate, and a bundle of the system's roots plus the + /// CA, into `dir`. SSL_CERT_FILE and the variables like it replace the + /// trust store rather than add to it, so what they point at has to hold the + /// system's roots as well, or every other host would stop verifying. No + /// bundle when the system's roots cannot be found. + pub(super) fn write_trust(&self, dir: &str) -> Result<(String, Option), String> { + let ca_path = format!("{dir}/ca.pem"); + std::fs::write(&ca_path, self.cert.pem()).map_err(|e| format!("cannot write {ca_path}: {e}"))?; + let Some(mut bundle) = system_roots() else { return Ok((ca_path, None)) }; + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(&self.cert.pem()); + let bundle_path = format!("{dir}/bundle.pem"); + std::fs::write(&bundle_path, bundle).map_err(|e| format!("cannot write {bundle_path}: {e}"))?; + Ok((ca_path, Some(bundle_path))) + } + + /// A TLS server configuration presenting a certificate for `host`, signed + /// by this CA. + pub(super) fn server_config_for(&self, host: &str) -> Result { + let mut params = rcgen::CertificateParams::new(vec![host.to_string()]).map_err(|e| format!("leaf for {host}: {e}"))?; + params.distinguished_name.push(rcgen::DnType::CommonName, host); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + // Strict verifiers (Python 3.13 and later, by default) refuse a leaf + // that does not say which key signed it. + params.use_authority_key_identifier_extension = true; + set_validity(&mut params); + let key = rcgen::KeyPair::generate().map_err(|e| format!("leaf key: {e}"))?; + let leaf = params.signed_by(&key, &self.cert, &self.key).map_err(|e| format!("leaf for {host}: {e}"))?; + let chain = vec![leaf.der().clone(), self.cert.der().clone()]; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())); + let mut config = rustls::ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .map_err(|e| format!("server config: {e}"))? + .with_no_client_auth() + .with_single_cert(chain, key) + .map_err(|e| format!("server config: {e}"))?; + // HTTP/1.1 alone: the proxy reads one request head and one body, and + // a client that would speak HTTP/2 falls back when it is not offered. + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(config) + } +} + +/// From yesterday to a week from now: long enough for any run, short enough +/// that a leaked certificate is soon worthless, and within the lifetimes +/// clients accept for a server certificate. +fn set_validity(params: &mut rcgen::CertificateParams) { + let days = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86400).unwrap_or(0) as i64; + let (y1, m1, d1) = civil_from_days(days - 1); + let (y2, m2, d2) = civil_from_days(days + 7); + params.not_before = rcgen::date_time_ymd(y1, m1, d1); + params.not_after = rcgen::date_time_ymd(y2, m2, d2); +} + +/// Days since 1970-01-01 as a calendar date (Howard Hinnant's algorithm). +pub(super) fn civil_from_days(days: i64) -> (i32, u8, u8) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 }; + (year as i32, month as u8, day as u8) +} + +/// The system's trust store as PEM: the host's own SSL_CERT_FILE if it has +/// one, else the first bundle a distribution keeps in its usual place. +fn system_roots() -> Option { + let mut candidates: Vec = std::env::var("SSL_CERT_FILE").into_iter().collect(); + candidates.extend( + ["/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem"] + .iter() + .map(|s| s.to_string()), + ); + candidates.iter().find_map(|path| std::fs::read_to_string(path).ok().filter(|text| text.contains("BEGIN CERTIFICATE"))) +} + +/// How porta verifies the real server: the webpki roots, plus, for the +/// integration suite alone, the CA named by PORTA_TEST_UPSTREAM_CA in porta's +/// own environment. +pub(super) fn upstream_config() -> Result, String> { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + if let Ok(path) = std::env::var("PORTA_TEST_UPSTREAM_CA") { + add_test_roots(&mut roots, &path)?; + } + let mut config = rustls::ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .map_err(|e| format!("client config: {e}"))? + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(Arc::new(config)) +} + +fn add_test_roots(roots: &mut rustls::RootCertStore, path: &str) -> Result<(), String> { + let certs = CertificateDer::pem_file_iter(path).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + for cert in certs { + let cert = cert.map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + roots.add(cert).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + } + Ok(()) +} diff --git a/native/http_proxy.rs b/native/http_proxy.rs index f83a4f7..84d5103 100644 --- a/native/http_proxy.rs +++ b/native/http_proxy.rs @@ -8,8 +8,11 @@ //! Split out of wasmtime_bridge so the FFI surface there stays a surface: this //! module owns the listener and the policy matching. +use crate::credential_broker::{broker_from_json, Broker, Target}; use crate::locking::locked; -use crate::proxy_egress::{basic_credential, dial, mint_token, Dial}; +use crate::proxy_egress::{basic_credential, mint_token, tunnel}; +/// Kept reachable here, where the FFI re-exports have always found it. +pub use crate::proxy_egress::wt_is_host_allowed; use crate::proxy_audit::{audit_log, Decision}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -33,11 +36,17 @@ struct ProxyPolicy { /// carrying this run's allow-list. The token is minted per run and handed /// to the child in its `HTTPS_PROXY`. credential: String, + /// The credentials handed in as placeholders (#37), and the CA their + /// hosts' connections are opened with. None when the run has none: then + /// every CONNECT is a tunnel, as it always was. + broker: Option>, } struct ProxyInstance { port: u16, shutdown: Arc, + /// The broker's CA and trust bundle, removed when the proxy stops. + credentials_dir: Option, } static PROXIES: Mutex>> = Mutex::new(Vec::new()); @@ -48,7 +57,7 @@ static PROXIES: Mutex>> = Mutex::new(Vec::new()); /// the same name, as DNS reads it: `evil.com.` resolves where `evil.com` does, /// and before this a deny-list naming `evil.com` let it through (found by /// `scripts/fuzz.py proxy`). -fn host_matches(host: &str, pattern: &str) -> bool { +pub(crate) fn host_matches(host: &str, pattern: &str) -> bool { let host = host.strip_suffix('.').unwrap_or(host); let pattern = pattern.strip_suffix('.').unwrap_or(pattern); if pattern.eq_ignore_ascii_case(host) { @@ -77,25 +86,16 @@ fn policy_allows(policy: &ProxyPolicy, host: &str) -> bool { } -fn copy_bytes(mut src: TcpStream, mut dst: TcpStream) -> std::io::Result<()> { - let mut buf = [0u8; 8192]; - loop { - let n = src.read(&mut buf)?; - if n == 0 { - let _ = dst.shutdown(std::net::Shutdown::Write); - return Ok(()); - } - dst.write_all(&buf[..n])?; - } -} - fn handle_connection(client: TcpStream, policy: Arc, audit_path: Arc>) { let _ = client.set_read_timeout(Some(Duration::from_secs(30))); let Ok(mut client_for_write) = client.try_clone() else { return }; let mut reader = BufReader::new(client); let Some((request_line, headers)) = read_request(&mut reader) else { return }; match connect_target(&request_line, &headers, &policy) { - Ok((host, port)) => tunnel(client_for_write, &audit_path, &host, port), + Ok((host, port)) => match &policy.broker { + Some(broker) if broker.intercepts(Target { host: &host, port }) => broker.intercept(client_for_write, &audit_path, Target { host: &host, port }), + _ => tunnel(client_for_write, &audit_path, &host, port), + }, Err((status, decision)) => { let _ = client_for_write.write_all(status.as_bytes()); audit_log(&audit_path, &decision); @@ -162,41 +162,23 @@ fn split_authority(target: &str) -> (String, u16) { } } -/// Opens the tunnel and copies bytes both ways until either side closes. An -/// unreachable upstream is answered and recorded rather than left hanging. -fn tunnel(mut client: TcpStream, audit_path: &Option, host: &str, port: u16) { - let upstream = match dial(host, port) { - Ok(upstream) => upstream, - Err(Dial::Blocked(reason)) => { - let _ = client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n"); - return audit_log(audit_path, &Decision::new(host, port, "deny", reason)); - } - Err(Dial::Failed(reason)) => { - let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); - return audit_log(audit_path, &Decision::new(host, port, "error", reason)); - } - }; - let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); - audit_log(audit_path, &Decision::new(host, port, "allow", "policy match")); - let (Ok(client_side), Ok(upstream_side)) = (client.try_clone(), upstream.try_clone()) else { return }; - let outbound = thread::spawn(move || { let _ = copy_bytes(client_side, upstream); }); - let inbound = thread::spawn(move || { let _ = copy_bytes(upstream_side, client); }); - let _ = outbound.join(); - let _ = inbound.join(); -} - /// Start the CONNECT proxy on 127.0.0.1:. /// `allow_json` and `deny_json` are JSON arrays of hostname patterns; only one /// should be non-empty. `audit_path` is a file path for JSONL logging (empty = disabled). -/// Returns JSON: {"handle":,"port":} on success, {"error":"..."} otherwise. +/// `credentials_json` is `[{"name","value","hosts":[...]}]`, the credentials to +/// hand in as placeholders; with any, the proxy may run with neither list, +/// and then every host is reachable through it. +/// Returns JSON: {"handle","port","token","env":[[name,value],...]} on success, +/// {"error":"..."} otherwise. `env` is what the command's environment gains. pub fn wt_proxy_start( allow_json: impl AsRef, deny_json: impl AsRef, audit_path: impl AsRef, + credentials_json: impl AsRef, ) -> String { - let policy = match requested_policy(allow_json.as_ref(), deny_json.as_ref()) { - Ok(policy) => policy, - Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), + let (broker, policy) = match proxy_policy(allow_json.as_ref(), deny_json.as_ref(), credentials_json.as_ref()) { + Ok(ready) => ready, + Err(reason) => return error_json(&reason), }; let listener = match loopback_listener() { Ok(listener) => listener, @@ -209,28 +191,56 @@ pub fn wt_proxy_start( let shutdown = Arc::new(AtomicBool::new(false)); let audit = Some(audit_path.as_ref().to_string()).filter(|path| !path.is_empty()); let token = mint_token(); - let policy = ProxyPolicy { credential: basic_credential(&token), ..policy }; + let env: Vec<(String, String)> = broker.as_ref().map(|b| b.child_env()).unwrap_or_default(); + let credentials_dir = broker.as_ref().map(|b| b.dir.clone()); + let policy = ProxyPolicy { credential: basic_credential(&token), broker, ..policy }; serve(listener, Arc::new(policy), Arc::new(audit), shutdown.clone()); let handle = { let mut proxies = locked(&PROXIES); - proxies.push(Some(ProxyInstance { port, shutdown })); + proxies.push(Some(ProxyInstance { port, shutdown, credentials_dir })); (proxies.len() - 1) as i64 }; - format!("{{\"handle\":{},\"port\":{},\"token\":\"{}\"}}", handle, port, token) + format!( + "{{\"handle\":{},\"port\":{},\"token\":\"{}\",\"env\":{}}}", + handle, + port, + token, + serde_json::to_string(&env).unwrap_or_else(|_| "[]".into()) + ) } -/// The policy these two lists ask for. Exactly one of them must be given: -/// an allow-list and a deny-list together have no single meaning. The -/// credential is filled in by the caller once the run's token is minted. -fn requested_policy(allow_json: &str, deny_json: &str) -> Result { +fn error_json(reason: &str) -> String { + format!("{{\"error\":{}}}", serde_json::to_string(reason).unwrap_or_else(|_| "\"\"".into())) +} + +/// The broker for the run's credentials, if any, and the policy the lists ask +/// for. A credential bound to a host an allow-list leaves out would be handed +/// in for nothing; binding it says the host is meant to be reached, so it is +/// added. +fn proxy_policy(allow_json: &str, deny_json: &str, credentials_json: &str) -> Result<(Option>, ProxyPolicy), String> { + let broker = broker_from_json(credentials_json)?; + let mut policy = requested_policy(allow_json, deny_json, broker.is_some()).map_err(str::to_string)?; + if let (ProxyMode::Allow, Some(broker)) = (policy.mode, &broker) { + policy.patterns.extend(broker.hosts()); + } + Ok((broker, policy)) +} + +/// The policy these two lists ask for. At most one of them may be given: an +/// allow-list and a deny-list together have no single meaning. Neither is an +/// error unless the run hands credentials in, which is reason enough for a +/// proxy: then nothing is denied. The credential and the broker are filled in +/// by the caller. +fn requested_policy(allow_json: &str, deny_json: &str, has_credentials: bool) -> Result { let allow: Vec = serde_json::from_str(allow_json).unwrap_or_default(); let deny: Vec = serde_json::from_str(deny_json).unwrap_or_default(); let credential = String::new(); match (allow.is_empty(), deny.is_empty()) { (false, false) => Err("allow and deny are mutually exclusive"), - (false, true) => Ok(ProxyPolicy { mode: ProxyMode::Allow, patterns: allow, credential }), - (true, false) => Ok(ProxyPolicy { mode: ProxyMode::Deny, patterns: deny, credential }), + (false, true) => Ok(ProxyPolicy { mode: ProxyMode::Allow, patterns: allow, credential, broker: None }), + (true, false) => Ok(ProxyPolicy { mode: ProxyMode::Deny, patterns: deny, credential, broker: None }), + (true, true) if has_credentials => Ok(ProxyPolicy { mode: ProxyMode::Deny, patterns: vec![], credential, broker: None }), (true, true) => Err("neither allow nor deny list provided"), } } @@ -271,23 +281,10 @@ pub fn wt_proxy_stop(handle: i64) -> i64 { } if let Some(inst) = &proxies[idx] { inst.shutdown.store(true, Ordering::Relaxed); + if let Some(dir) = &inst.credentials_dir { + let _ = std::fs::remove_dir_all(dir); + } } proxies[idx] = None; 0 } - - -/// Use the same URL parser as the HTTP transport, never a string split. -pub fn wt_is_host_allowed(url: impl AsRef, allowed_json: impl AsRef) -> bool { - let Ok(url) = reqwest::Url::parse(url.as_ref()) else { return false }; - if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() { - return false; - } - let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else { return false }; - let Ok(allowed) = serde_json::from_str::>(allowed_json.as_ref()) else { return false }; - allowed.iter().any(|rule| { - let Some((allowed_host, allowed_port)) = rule.rsplit_once(':') else { return false }; - (allowed_host == "*" || allowed_host.eq_ignore_ascii_case(host)) && - (allowed_port == "*" || allowed_port.parse::().ok() == Some(port)) - }) -} diff --git a/native/proxy_egress.rs b/native/proxy_egress.rs index 2928a6d..f5e4a19 100644 --- a/native/proxy_egress.rs +++ b/native/proxy_egress.rs @@ -6,8 +6,10 @@ //! Kept apart from `http_proxy` so that module stays the listener and the //! policy match, and this one holds the credential and the dial. -use std::io::Read; +use crate::proxy_audit::{audit_log, Decision}; +use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; +use std::thread; use std::time::Duration; /// A fresh token for one run: 16 random bytes, as hex. A label for a client, @@ -79,6 +81,9 @@ fn blocked_v6(v6: &std::net::Ipv6Addr) -> bool { /// connected within ten seconds. Resolved once and dialled by address, so /// what was checked is what is connected. pub(crate) fn dial(host: &str, port: u16) -> Result { + if let Some(address) = test_upstream(host) { + return TcpStream::connect_timeout(&address, Duration::from_secs(10)).map_err(|e| Dial::Failed(format!("upstream connect failed: {e}"))); + } let addresses: Vec = (host, port) .to_socket_addrs() .map_err(|e| Dial::Failed(format!("resolve failed: {e}")))? @@ -95,3 +100,66 @@ pub(crate) fn dial(host: &str, port: u16) -> Result { TcpStream::connect_timeout(address, Duration::from_secs(10)) .map_err(|e| Dial::Failed(format!("upstream connect failed: {e}"))) } + +/// For scripts/integration.py alone: `PORTA_TEST_UPSTREAM=host=127.0.0.1:port,...` +/// sends a CONNECT for `host` to a local test server, past the address guard, +/// so the credential broker can be exercised end to end without the internet. +/// It is read from porta's own environment, which the confined command +/// cannot set; whoever can set it already runs porta. +fn test_upstream(host: &str) -> Option { + let spec = std::env::var("PORTA_TEST_UPSTREAM").ok()?; + spec.split(',').find_map(|entry| { + let (name, address) = entry.split_once('=')?; + if name.trim().eq_ignore_ascii_case(host) { address.trim().parse().ok() } else { None } + }) +} + +/// Opens the tunnel and copies bytes both ways until either side closes. An +/// unreachable upstream is answered and recorded rather than left hanging. +pub(crate) fn tunnel(mut client: TcpStream, audit_path: &Option, host: &str, port: u16) { + let upstream = match dial(host, port) { + Ok(upstream) => upstream, + Err(Dial::Blocked(reason)) => { + let _ = client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n"); + return audit_log(audit_path, &Decision::new(host, port, "deny", reason)); + } + Err(Dial::Failed(reason)) => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); + return audit_log(audit_path, &Decision::new(host, port, "error", reason)); + } + }; + let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); + audit_log(audit_path, &Decision::new(host, port, "allow", "policy match")); + let (Ok(client_side), Ok(upstream_side)) = (client.try_clone(), upstream.try_clone()) else { return }; + let outbound = thread::spawn(move || { let _ = copy_bytes(client_side, upstream); }); + let inbound = thread::spawn(move || { let _ = copy_bytes(upstream_side, client); }); + let _ = outbound.join(); + let _ = inbound.join(); +} + +fn copy_bytes(mut src: TcpStream, mut dst: TcpStream) -> std::io::Result<()> { + let mut buf = [0u8; 8192]; + loop { + let n = src.read(&mut buf)?; + if n == 0 { + let _ = dst.shutdown(std::net::Shutdown::Write); + return Ok(()); + } + dst.write_all(&buf[..n])?; + } +} + +/// Use the same URL parser as the HTTP transport, never a string split. +pub fn wt_is_host_allowed(url: impl AsRef, allowed_json: impl AsRef) -> bool { + let Ok(url) = reqwest::Url::parse(url.as_ref()) else { return false }; + if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() { + return false; + } + let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else { return false }; + let Ok(allowed) = serde_json::from_str::>(allowed_json.as_ref()) else { return false }; + allowed.iter().any(|rule| { + let Some((allowed_host, allowed_port)) = rule.rsplit_once(':') else { return false }; + (allowed_host == "*" || allowed_host.eq_ignore_ascii_case(host)) && + (allowed_port == "*" || allowed_port.parse::().ok() == Some(port)) + }) +} diff --git a/scripts/integration.py b/scripts/integration.py index 3702415..f34be1e 100644 --- a/scripts/integration.py +++ b/scripts/integration.py @@ -2,6 +2,8 @@ """Offline process-level regressions; run against the newly built binary.""" import json import http.server +import os +import ssl import threading import pathlib import platform @@ -168,6 +170,92 @@ def denied(attempt): thread.join() print('PASS: checked HTTP tool does not follow redirects') + # Credentials handed in as placeholders (#37). The command sees + # porta-cred-TEST_KEY-; the proxy opens its TLS to the bound host + # with the run's CA, puts the real value on, and verifies the real server + # (here a local one, through the test-only PORTA_TEST_UPSTREAM and + # PORTA_TEST_UPSTREAM_CA). The same placeholder on a path it is not bound + # to is refused before anything is sent; a host no credential names is + # tunnelled as before, its TLS never touched. + tls = root / 'credential-tls' + tls.mkdir() + def openssl(*args): + subprocess.run(['openssl', *args], cwd=tls, check=True, capture_output=True) + (tls / 'ca.ext').write_text('basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,digitalSignature\nsubjectKeyIdentifier=hash\n') + (tls / 'server.ext').write_text('subjectAltName=DNS:api.example.test,DNS:other.example.test\nextendedKeyUsage=serverAuth\n' + 'subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n') + openssl('req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'ca.key', '-out', 'ca.csr', '-subj', '/CN=porta test upstream CA') + openssl('x509', '-req', '-in', 'ca.csr', '-signkey', 'ca.key', '-out', 'ca.pem', '-days', '2', '-extfile', 'ca.ext') + openssl('req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'server.key', '-out', 'server.csr', '-subj', '/CN=api.example.test') + openssl('x509', '-req', '-in', 'server.csr', '-CA', 'ca.pem', '-CAkey', 'ca.key', '-CAcreateserial', '-out', 'server.pem', + '-days', '2', '-extfile', 'server.ext') + seen = [] + class KeyedServer(http.server.BaseHTTPRequestHandler): + def do_GET(self): + seen.append((self.headers.get('Host', '').split(':')[0], self.path, self.headers.get('x-api-key'))) + body = b'upstream ok' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + """Silence the request log; these tests assert on their own output.""" + upstream = http.server.ThreadingHTTPServer(('127.0.0.1', 0), KeyedServer) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls / 'server.pem', tls / 'server.key') + upstream.socket = context.wrap_socket(upstream.socket, server_side=True) + upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) + upstream_thread.start() + probe = r"""import os, ssl, sys, urllib.error, urllib.request +key = os.environ['TEST_KEY'] +print('placeholder', key.startswith('porta-cred-TEST_KEY-') and 'real-secret' not in key) +def get(url, cafile=None): + context = ssl.create_default_context(cafile=cafile) + opener = urllib.request.build_opener(urllib.request.ProxyHandler(), urllib.request.HTTPSHandler(context=context)) + try: + with opener.open(urllib.request.Request(url, headers={'x-api-key': key}), timeout=10) as reply: + return reply.status + except urllib.error.HTTPError as error: + return error.code +print('bound', get('https://api.example.test/v1/models')) +print('unbound-path', get('https://api.example.test/admin')) +print('unbound-host', get('https://other.example.test/v1/models', cafile=sys.argv[1])) +""" + audit = root / 'credential-audit.jsonl' + secret = 'real-secret-value-for-the-test' + try: + environment = {**os.environ, 'TEST_KEY': secret, 'PORTA_TEST_UPSTREAM_CA': str(tls / 'ca.pem'), + 'PORTA_TEST_UPSTREAM': f'api.example.test=127.0.0.1:{upstream.server_port},' + f'other.example.test=127.0.0.1:{upstream.server_port}'} + result = run('run', sys.executable, '--credential', 'TEST_KEY=api.example.test/v1', '--proxy-audit', str(audit), + '--', '-c', probe, str(tls / 'ca.pem'), env=environment) + assert result.returncode == 0, result.stderr + lines = dict(line.split(' ', 1) for line in result.stdout.splitlines()) + assert lines == {'placeholder': 'True', 'bound': '200', 'unbound-path': '403', 'unbound-host': '200'}, (result.stdout, result.stderr) + # The bound request reached the server with the real value; the refused + # one never reached it; the tunnelled one carried the placeholder. + assert ('api.example.test', '/v1/models', secret) in seen, seen + assert not any(path == '/admin' for _, path, _ in seen), seen + assert any(host == 'other.example.test' and key.startswith('porta-cred-TEST_KEY-') for host, _, key in seen), seen + trail = audit.read_text() + assert '"substitute"' in trail and 'credential TEST_KEY put on for /v1/models' in trail, trail + assert 'credential TEST_KEY is not bound to api.example.test/admin' in trail, trail + assert secret not in trail and secret not in result.stderr and secret not in result.stdout + # The run's CA and trust bundle go when the run does. + assert not list(pathlib.Path('/tmp').glob('porta-credentials-*')) or all( + p.stat().st_mtime < time.time() - 60 for p in pathlib.Path('/tmp').glob('porta-credentials-*')) + result = run('run', '/bin/sh', '--credential', 'PORTA_UNSET_FOR_TEST=api.example.test', '--', '-c', 'exit 0', + env={k: v for k, v in os.environ.items() if k != 'PORTA_UNSET_FOR_TEST'}) + assert result.returncode != 0 and 'PORTA_UNSET_FOR_TEST is not set' in result.stderr, result + result = run('run', '/bin/sh', '--credential', 'TEST_KEY=api.example.test:8443', '--', '-c', 'exit 0', env=environment) + assert result.returncode != 0 and 'port 443 only' in result.stderr, result + finally: + upstream.shutdown() + upstream.server_close() + upstream_thread.join() + print('PASS: a credential is a placeholder inside, put on for its bound host and path only; ' + 'refused elsewhere and recorded without its value; other hosts tunnelled untouched') + # A directory the run is never granted. It has to be somewhere an ordinary # user can create — porta refuses to run as root, so /opt is out — and # outside both the always-writable roots and the strict read set, or the diff --git a/src/cli.almd b/src/cli.almd index 6160fab..ea19b81 100644 --- a/src/cli.almd +++ b/src/cli.almd @@ -4,6 +4,7 @@ import process import self.dispatch import self.sandbox import self.manifest +import self.proxy import self.util type Options = { path: String, @@ -25,6 +26,8 @@ type Options = { proxy_allow: List[String], proxy_deny: List[String], proxy_audit: String, + // Credentials handed in as placeholders and put on in the proxy (--credential, [[credentials]]). + credentials: List[proxy.Credential], read_policy: String, allow_root: Bool, // Host variables copied into the child by name; its environment otherwise starts empty. @@ -75,6 +78,7 @@ fn default_options() -> Options = { proxy_allow: [], proxy_deny: [], proxy_audit: "", + credentials: [], read_policy: "open", allow_root: false, env_pass: [], @@ -130,6 +134,7 @@ fn apply_option(opts: Options, arg: String, next: String) -> Result[ParsedOption "--proxy-allow" => valued({...opts, proxy_allow: opts.proxy_allow + string.split(next, ",")}), "--proxy-deny" => valued({...opts, proxy_deny: opts.proxy_deny + string.split(next, ",")}), "--proxy-audit" => valued({...opts, proxy_audit: next}), + "--credential" => valued({...opts, credentials: opts.credentials + list.filter_map([next], proxy.parse_credential)}), "--read-policy" => valued({...opts, read_policy: next}), "--allow-root" => flag({...opts, allow_root: true}), "--no-net" => flag({...opts, no_net: true}), @@ -252,14 +257,6 @@ fn build_config(wasm_path: String, opts: Options, caps: sandbox.CapabilitySet, e // --- Policy capture --- -// A TOML basic string: backslash and double-quote escaped so a path holding -// either stays valid TOML rather than closing the string early. -fn toml_quote(s: String) -> String = - "\"" + string.replace(string.replace(s, "\\", "\\\\"), "\"", "\\\"") + "\"" - -fn toml_strings(items: List[String]) -> String = - "[" + string.join(items |> list.map(toml_quote), ", ") + "]" - fn line_when(present: Bool, line: String) -> List[String] = if present then [line] else [] @@ -272,28 +269,29 @@ fn policy_toml(opts: Options) -> String = { let proxy_on = list.len(opts.proxy_allow) > 0 or list.len(opts.proxy_deny) > 0 or opts.proxy_audit != "" let has_env = list.len(opts.env_vars) > 0 or list.len(opts.secrets) > 0 let lines = - ["[runtime]", "type = \"native\"", "command = " + toml_quote(opts.path), "", "[sandbox]", - "mounts = " + toml_strings(mounts)] - + line_when(list.len(opts.allow_net) > 0, "network = " + toml_strings(opts.allow_net)) + ["[runtime]", "type = \"native\"", "command = " + util.toml_quote(opts.path), "", "[sandbox]", + "mounts = " + util.toml_strings(mounts)] + + line_when(list.len(opts.allow_net) > 0, "network = " + util.toml_strings(opts.allow_net)) + line_when(opts.no_net, "no-net = true") - + line_when(opts.preset != "", "preset = " + toml_quote(opts.preset)) - + line_when(list.len(opts.deny_read) > 0, "deny-read = " + toml_strings(opts.deny_read)) - + line_when(list.len(opts.protect) > 0, "protect = " + toml_strings(opts.protect)) - + line_when(list.len(opts.deny_unix) > 0, "deny-unix = " + toml_strings(opts.deny_unix)) + + line_when(opts.preset != "", "preset = " + util.toml_quote(opts.preset)) + + line_when(list.len(opts.deny_read) > 0, "deny-read = " + util.toml_strings(opts.deny_read)) + + line_when(list.len(opts.protect) > 0, "protect = " + util.toml_strings(opts.protect)) + + line_when(list.len(opts.deny_unix) > 0, "deny-unix = " + util.toml_strings(opts.deny_unix)) + line_when(opts.read_policy == "strict", "read-policy = \"strict\"") - + line_when(list.len(opts.env_pass) > 0, "env-pass = " + toml_strings(opts.env_pass)) - + line_when(list.len(opts.allow_unix) > 0, "unix = " + toml_strings(opts.allow_unix)) - + line_when(list.len(opts.allow_bind) > 0, "bind = " + toml_strings(opts.allow_bind)) + + line_when(list.len(opts.env_pass) > 0, "env-pass = " + util.toml_strings(opts.env_pass)) + + line_when(list.len(opts.allow_unix) > 0, "unix = " + util.toml_strings(opts.allow_unix)) + + line_when(list.len(opts.allow_bind) > 0, "bind = " + util.toml_strings(opts.allow_bind)) + line_when(opts.timeout > 0, "timeout = " + int.to_string(opts.timeout)) + line_when(opts.max_cpu > 0, "max-cpu = " + int.to_string(opts.max_cpu)) + line_when(opts.max_procs > 0, "max-procs = " + int.to_string(opts.max_procs)) + line_when(opts.max_file_size > 0, "max-file-size = " + int.to_string(opts.max_file_size)) + line_when(opts.max_memory_mb > 0, "max-memory-mb = " + int.to_string(opts.max_memory_mb)) - + line_when(opts.profile != "", "profile = " + toml_quote(opts.profile)) + + line_when(opts.profile != "", "profile = " + util.toml_quote(opts.profile)) + (if proxy_on then ["", "[proxy]"] else []) - + line_when(list.len(opts.proxy_allow) > 0, "allow = " + toml_strings(opts.proxy_allow)) - + line_when(list.len(opts.proxy_deny) > 0, "deny = " + toml_strings(opts.proxy_deny)) - + line_when(opts.proxy_audit != "", "audit = " + toml_quote(opts.proxy_audit)) + + line_when(list.len(opts.proxy_allow) > 0, "allow = " + util.toml_strings(opts.proxy_allow)) + + line_when(list.len(opts.proxy_deny) > 0, "deny = " + util.toml_strings(opts.proxy_deny)) + + line_when(opts.proxy_audit != "", "audit = " + util.toml_quote(opts.proxy_audit)) + + proxy.toml_tables(opts.credentials) + line_when(has_env, "\n# -e values and secrets were not saved; add [env] and [secrets] yourself") string.join(lines, "\n") + "\n" } diff --git a/src/config.almd b/src/config.almd index bf0b14c..48486c5 100644 --- a/src/config.almd +++ b/src/config.almd @@ -6,6 +6,7 @@ import json import process import self.wasm_rt import self.dispatch +import self.proxy type PortaConfig = { runtime_type: String, @@ -24,6 +25,7 @@ type PortaConfig = { proxy_allow: List[String], proxy_deny: List[String], proxy_audit: String, + credentials: List[proxy.Credential], env_pass: List[String], allow_unix: List[String], allow_bind: List[String], @@ -56,6 +58,7 @@ fn default_porta_config() -> PortaConfig = { proxy_allow: [], proxy_deny: [], proxy_audit: "", + credentials: [], env_pass: [], allow_unix: [], allow_bind: [], @@ -97,6 +100,7 @@ effect fn load_porta_config(path: String) -> Result[PortaConfig, String] = { proxy_allow: strings_at(px, "allow", []), proxy_deny: strings_at(px, "deny", []), proxy_audit: json.get_string(px, "audit") ?? "", + credentials: credential_tables(v), env_pass: strings_at(sb, "env-pass", []), allow_unix: strings_at(sb, "unix", []), allow_bind: strings_at(sb, "bind", []), @@ -122,6 +126,15 @@ fn strings_at(table: Value, key: String, fallback: List[String]) -> List[String] some(items) => items |> list.filter_map((item) => match value.as_string(item) { ok(s) => some(s), err(_) => none }), } +// [[credentials]] — name, from = { env = "VAR" } (default: the name itself), +// hosts = ["host[:443][/path]", ...]. A table without a name is left out. +fn credential_tables(v: Value) -> List[proxy.Credential] = + (json.get_array(v, "credentials") ?? []) |> list.filter_map((t) => { + let name = json.get_string(t, "name") ?? "" + let from = json.get_string(value.field(t, "from") ?? value.object([]), "env") ?? name + if name == "" then none else some({name: name, from_env: from, hosts: strings_at(t, "hosts", [])}) + }) + // [env] — a plain key=value table. fn env_table(v: Value) -> List[dispatch.EnvVar] = { let env_obj = value.field(v, "env") ?? value.object([]) diff --git a/src/engine.almd b/src/engine.almd index b390b0f..95ffbdb 100644 --- a/src/engine.almd +++ b/src/engine.almd @@ -66,7 +66,7 @@ effect fn run_foreground(opts: cli.Options) -> Result[Unit, String] = { // --- Native sandbox execution --- fn proxy_config(opts: cli.Options) -> proxy.ProxyConfig = - {allow: opts.proxy_allow, deny: opts.proxy_deny, audit_path: opts.proxy_audit} + {allow: opts.proxy_allow, deny: opts.proxy_deny, audit_path: opts.proxy_audit, credentials: opts.credentials} // Runs a native command under the policy the options describe and returns // the command's own exit code, so the caller can exit with it. @@ -164,7 +164,7 @@ effect fn run_native_proxied(opts: cli.Options, proxy_cfg: proxy.ProxyConfig) -> {key: "http_proxy", val: endpoint}, {key: "https_proxy", val: endpoint}, {key: "NODE_USE_ENV_PROXY", val: "1"}, - ] + ] + h.env // Net: strictly the proxy port. Anything else must be blocked at the OS level. let request = native_request(opts, [proxy.sandbox_net_rule(h)], proxy_env, true)! let reply = wasm_rt.wt_exec_supervised(dispatch.exec_request_json(request)) diff --git a/src/help.almd b/src/help.almd index 5b32234..0588cbe 100644 --- a/src/help.almd +++ b/src/help.almd @@ -18,6 +18,8 @@ fn print_common_runtime_opts() -> Unit = { println(" --proxy-allow HTTPS proxy allowlist (comma-separated, *.wildcard ok)") println(" --proxy-deny HTTPS proxy denylist (comma-separated, *.wildcard ok)") println(" --proxy-audit Append JSONL proxy decisions to file") + println(" --credential Hand host variable NAME in as a placeholder; the proxy puts the real") + println(" value on for HOST[/path] only (repeatable, comma-separated hosts)") println(" --read-policy open (default) or strict: confine reads to mounts") println(" --allow-root Run as root anyway (file permissions then protect nothing)") println(" --env-pass Copy these host variables in (the rest stay outside)") diff --git a/src/project.almd b/src/project.almd index b188f1b..961d1d8 100644 --- a/src/project.almd +++ b/src/project.almd @@ -28,6 +28,7 @@ effect fn porta_up(extra_args: List[String]) -> Result[Int, String] = { proxy_allow: cfg.proxy_allow, proxy_deny: cfg.proxy_deny, proxy_audit: cfg.proxy_audit, + credentials: cfg.credentials, env_pass: cfg.env_pass, allow_unix: cfg.allow_unix, allow_bind: cfg.allow_bind, diff --git a/src/proxy.almd b/src/proxy.almd index 95f461e..cc91386 100644 --- a/src/proxy.almd +++ b/src/proxy.almd @@ -3,12 +3,27 @@ import value // Almide owns config shape and lifecycle; the accept loop runs in a native thread. import json +import process import self.wasm_rt +import self.dispatch +import self.util + +// A credential handed in as a placeholder (#37): the name the command sees it +// under, the host variable its real value is read from, and the hosts it may +// be put on for (`host[:443][/path]`). The command never holds the value; the +// proxy puts it on requests to those hosts and refuses the placeholder +// anywhere else it opens. +type Credential = { + name: String, + from_env: String, + hosts: List[String], +} type ProxyConfig = { allow: List[String], deny: List[String], audit_path: String, + credentials: List[Credential], } type ProxyHandle = { @@ -18,16 +33,23 @@ type ProxyHandle = { // process of the same user can reach; only a client presenting the token // is served, so the proxy is not an open relay carrying this run's policy. token: String, + // What the command's environment gains with credentials: each placeholder + // under its credential's name, and the variables that make TLS clients + // trust the run's CA. Empty without credentials. + env: List[dispatch.EnvVar], } fn empty_config() -> ProxyConfig = { allow: [], deny: [], audit_path: "", + credentials: [], } +// A credential needs the proxy even with no list: it is where the real value +// is put on. fn is_enabled(c: ProxyConfig) -> Bool = - list.len(c.allow) > 0 or list.len(c.deny) > 0 + list.len(c.allow) > 0 or list.len(c.deny) > 0 or list.len(c.credentials) > 0 // Mutually exclusive: returns an error message when both allow and deny are set. fn validate(c: ProxyConfig) -> Result[Unit, String] = @@ -39,7 +61,8 @@ effect fn start(c: ProxyConfig) -> Result[ProxyHandle, String] = { validate(c)! let allow_json = json.stringify(value.array(c.allow |> list.map(value.str))) let deny_json = json.stringify(value.array(c.deny |> list.map(value.str))) - let result_json = wasm_rt.wt_proxy_start(allow_json, deny_json, c.audit_path) + let credentials_json = json.stringify(value.array(credential_values(c.credentials)!)) + let result_json = wasm_rt.wt_proxy_start(allow_json, deny_json, c.audit_path, credentials_json) let parsed = json.parse(result_json)! let error = json.get_string(parsed, "error") ?? "" if error != "" then err(error) @@ -48,10 +71,34 @@ effect fn start(c: ProxyConfig) -> Result[ProxyHandle, String] = { let port = json.get_int(parsed, "port") ?? 0 let token = json.get_string(parsed, "token") ?? "" if handle < 0 or port == 0 or token == "" then err("proxy start returned invalid handle, port or token") - else ok({handle: handle, port: port, token: token}) + else ok({handle: handle, port: port, token: token, env: env_pairs(parsed)}) + } +} + +// The credentials as the proxy takes them, each with its real value read from +// porta's own environment now. A variable the host does not have is an error +// here, before anything runs, rather than a key handed in empty. +effect fn credential_values(credentials: List[Credential]) -> Result[List[Value], String] = { + var out: List[Value] = [] + var missing = "" + for c in credentials { + let found = process.env(c.from_env) ?? "" + if found == "" and missing == "" then missing = "credential ${c.name}: ${c.from_env} is not set in porta's environment, so there is nothing to hand in" + out = out + [value.object([("name", value.str(c.name)), ("value", value.str(found)), + ("hosts", value.array(c.hosts |> list.map(value.str)))])] } + if missing != "" then err(missing) else ok(out) } +fn env_pairs(parsed: Value) -> List[dispatch.EnvVar] = + (json.get_array(parsed, "env") ?? []) |> list.filter_map((pair) => { + let items = value.as_array(pair) ?? [] + match (list.get(items, 0), list.get(items, 1)) { + (some(k), some(v)) => some({key: value.as_string(k) ?? "", val: value.as_string(v) ?? ""}), + _ => none, + } + }) + effect fn stop(h: ProxyHandle) -> Result[Unit, String] = { let rc = wasm_rt.wt_proxy_stop(h.handle) if rc == 0 then ok(()) @@ -64,3 +111,19 @@ fn endpoint(h: ProxyHandle) -> String = "http://porta:${h.token}@127.0.0.1:${int // sandbox-exec allow rule that restricts net to just the proxy port. fn sandbox_net_rule(h: ProxyHandle) -> String = "127.0.0.1:${int.to_string(h.port)}" + +// --credential NAME=HOST[,HOST...]: the host variable NAME, handed in as a +// placeholder and put on for requests to these hosts (`host[:443][/path]`). +fn parse_credential(text: String) -> Credential? = + match util.split_at_eq(text) { + some(kv) => some({name: kv.key, from_env: kv.key, + hosts: string.split(kv.val, ",") |> list.map(string.trim) |> list.filter((h) => h != "")}), + none => none, + } + +// [[credentials]] tables for a saved policy: where each credential goes and +// which variable it is read from, never its value. +fn toml_tables(credentials: List[Credential]) -> List[String] = + credentials |> list.flat_map((c) => + ["", "[[credentials]]", "name = " + util.toml_quote(c.name), "from = { env = " + util.toml_quote(c.from_env) + " }", + "hosts = " + util.toml_strings(c.hosts)]) diff --git a/src/proxy_test.almd b/src/proxy_test.almd index 4c2a469..10f0388 100644 --- a/src/proxy_test.almd +++ b/src/proxy_test.almd @@ -1,3 +1,4 @@ +import self.cli import self.proxy test "is_enabled: empty config is disabled" { @@ -6,17 +7,17 @@ test "is_enabled: empty config is disabled" { } test "is_enabled: allow-only enables proxy" { - let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: ""} + let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: "", credentials: []} assert_eq(proxy.is_enabled(c), true) } test "is_enabled: deny-only enables proxy" { - let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: ""} + let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: "", credentials: []} assert_eq(proxy.is_enabled(c), true) } test "validate: allow only is ok" { - let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: ""} + let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: "", credentials: []} match proxy.validate(c) { ok(_) => assert(true), err(_) => assert(false), @@ -24,7 +25,7 @@ test "validate: allow only is ok" { } test "validate: deny only is ok" { - let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: ""} + let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: "", credentials: []} match proxy.validate(c) { ok(_) => assert(true), err(_) => assert(false), @@ -32,7 +33,7 @@ test "validate: deny only is ok" { } test "validate: both allow and deny is an error" { - let c: proxy.ProxyConfig = {allow: ["a.com"], deny: ["b.com"], audit_path: ""} + let c: proxy.ProxyConfig = {allow: ["a.com"], deny: ["b.com"], audit_path: "", credentials: []} match proxy.validate(c) { ok(_) => assert(false), err(msg) => assert(string.contains(msg, "mutually exclusive")), @@ -40,11 +41,31 @@ test "validate: both allow and deny is an error" { } test "endpoint: carries the run's credential to 127.0.0.1:PORT" { - let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123"} + let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123", env: []} assert_eq(proxy.endpoint(h), "http://porta:abc123@127.0.0.1:42123") } test "sandbox_net_rule: formats as 127.0.0.1:PORT, no credential" { - let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123"} + let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123", env: []} assert_eq(proxy.sandbox_net_rule(h), "127.0.0.1:42123") } + +test "is_enabled: a credential alone needs the proxy" { + let c: proxy.ProxyConfig = {...proxy.empty_config(), credentials: [{name: "KEY", from_env: "KEY", hosts: ["api.example.com"]}]} + assert_eq(proxy.is_enabled(c), true) +} + +test "--credential NAME=HOSTS reads NAME from the host and binds it to each host" { + let opts = cli.parse_options(["run", "--credential", "KEY=api.example.com/v1, other.example.com", "--credential", "B=b.example.com"], 1, cli.default_options()) ?? cli.default_options() + assert_eq(list.len(opts.credentials), 2) + let first = list.get(opts.credentials, 0) ?? {name: "", from_env: "", hosts: []} + assert_eq(first.name, "KEY") + assert_eq(first.from_env, "KEY") + assert_eq(first.hosts, ["api.example.com/v1", "other.example.com"]) +} + +test "explain --save writes where a credential goes, never its value" { + let opts = {...cli.default_options(), path: "/bin/sh", credentials: [{name: "KEY", from_env: "HOST_KEY", hosts: ["api.example.com"]}]} + let saved = cli.policy_toml(opts) + assert(string.contains(saved, "[[credentials]]\nname = \"KEY\"\nfrom = { env = \"HOST_KEY\" }\nhosts = [\"api.example.com\"]")) +} diff --git a/src/util.almd b/src/util.almd index 711ec44..223a25e 100644 --- a/src/util.almd +++ b/src/util.almd @@ -73,3 +73,11 @@ fn resolve_serve_caps(profile: String, manifest_caps: List[String]) -> sandbox.C } else if list.len(manifest_caps) > 0 then sandbox.from_manifest(manifest_caps) else sandbox.profile_ai_agent() + +// A TOML basic string: backslash and double-quote escaped so a path holding +// either stays valid TOML rather than closing the string early. +fn toml_quote(s: String) -> String = + "\"" + string.replace(string.replace(s, "\\", "\\\\"), "\"", "\\\"") + "\"" + +fn toml_strings(items: List[String]) -> String = + "[" + string.join(items |> list.map(toml_quote), ", ") + "]" diff --git a/src/wasm_rt.almd b/src/wasm_rt.almd index 5f44211..3fedcea 100644 --- a/src/wasm_rt.almd +++ b/src/wasm_rt.almd @@ -100,7 +100,7 @@ fn wt_sandbox_check() -> String fn wt_sandbox_check_json() -> String @extern(rs, "wasmtime_bridge", "wt_proxy_start") -fn wt_proxy_start(allow_json: String, deny_json: String, audit_path: String) -> String +fn wt_proxy_start(allow_json: String, deny_json: String, audit_path: String, credentials_json: String) -> String @extern(rs, "wasmtime_bridge", "wt_proxy_stop") fn wt_proxy_stop(handle: Int) -> Int