From 0a397bd1e61388a3efec749be2b5288753d6b039 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Mon, 28 Sep 2026 09:52:51 +0900 Subject: [PATCH 1/2] Credentials as placeholders, put on by the proxy for bound hosts only `--credential NAME=HOST[/path]` and `[[credentials]]` hand the command a placeholder, porta-cred-NAME-, instead of the key. The real value, read from porta's environment, stays in porta. A credential turns the proxy on; for a bound host the proxy terminates the command's TLS with a certificate from a CA made for this run, puts the value where the placeholder is (request line, query, headers), and sends the request on over its own TLS connection verified against the webpki roots. The same placeholder on a path it is not bound to is refused with 403 before anything is sent. Substitutions and refusals are recorded by name, never by value. Hosts no credential names are tunnelled as before. The command trusts the CA through SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and GIT_SSL_CAINFO (system roots plus the CA) and NODE_EXTRA_CA_CERTS (the CA alone). The CA lives for a week, in memory; its files are removed when the proxy stops. rustls and rcgen on ring, the provider reqwest already builds: no second crypto library and no OpenSSL. PORTA_TEST_UPSTREAM(_CA) lets the integration suite send a CONNECT to a local server; it is read from porta's own environment. Closes #37 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 6 +- README.md | 4 + almide.toml | 6 + docs/cli.md | 6 + docs/enforcement.md | 46 ++- docs/threat-model.md | 5 + native/credential_broker.rs | 548 ++++++++++++++++++++++++++++++++++++ native/http_proxy.rs | 76 ++++- native/proxy_egress.rs | 16 ++ scripts/integration.py | 88 ++++++ src/cli.almd | 20 ++ src/config.almd | 13 + src/engine.almd | 4 +- src/help.almd | 2 + src/project.almd | 1 + src/proxy.almd | 52 +++- src/proxy_test.almd | 35 ++- src/wasm_rt.almd | 2 +- 18 files changed, 901 insertions(+), 29 deletions(-) create mode 100644 native/credential_broker.rs 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..95a842a --- /dev/null +++ b/native/credential_broker.rs @@ -0,0 +1,548 @@ +//! 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. +//! +//! 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 crate::proxy_audit::{audit_log, Decision}; +use crate::proxy_egress::{dial, mint_token, Dial}; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// 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, +} + +/// `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 covers(&self, host: &str, port: u16, path: &str) -> bool { + crate::http_proxy::host_matches(host, &self.host) && self.port == port && path_under(path, &self.path) + } +} + +/// Whether `path` (a request target, query and all) is `prefix` or below it: +/// `/v1` covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`. +fn path_under(target: &str, prefix: &str) -> bool { + if prefix.is_empty() { + return true; + } + let path = 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, host: &str, port: u16, target: &str) -> Rewrite { + let mut out = head.to_string(); + let mut used = Vec::new(); + for credential in credentials { + if !out.contains(&credential.placeholder) { + continue; + } + if !credential.bindings.iter().any(|binding| binding.covers(host, port, target)) { + return Rewrite::Refuse(credential.name.clone()); + } + out = out.replace(&credential.placeholder, &credential.value); + used.push(credential.name.clone()); + } + Rewrite::Send(out, used) +} + +/// The CA made for one run, and the leaf certificates it has signed so far. +pub(crate) struct Broker { + pub(crate) credentials: Vec, + ca: rcgen::Certificate, + ca_key: rcgen::KeyPair, + leaves: Mutex>>, + upstream: Arc, + /// The directory holding the CA certificate and the trust bundle, removed + /// when the proxy stops. + pub(crate) dir: String, + pub(crate) ca_path: String, + pub(crate) 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, +} + +fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) +} + +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. + pub(crate) fn new(requested: Vec) -> Result { + let credentials = merged(requested)?; + let (ca, ca_key) = make_ca()?; + 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 = format!("{dir}/ca.pem"); + std::fs::write(&ca_path, ca.pem()).map_err(|e| format!("cannot write {ca_path}: {e}"))?; + // 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 the command reaches + // would stop verifying. + let bundle_path = match system_roots() { + Some(roots) => { + let path = format!("{dir}/bundle.pem"); + let mut bundle = roots; + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(&ca.pem()); + std::fs::write(&path, bundle).map_err(|e| format!("cannot write {path}: {e}"))?; + Some(path) + } + None => None, + }; + Ok(Broker { credentials, ca, ca_key, leaves: Mutex::new(HashMap::new()), upstream: 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, host: &str, port: u16) -> bool { + self.credentials.iter().any(|c| c.bindings.iter().any(|b| crate::http_proxy::host_matches(host, &b.host) && b.port == port)) + } + + /// 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.mint_leaf(host)?); + leaves.insert(host.to_string(), config.clone()); + Ok(config) + } + + fn mint_leaf(&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.ca, &self.ca_key).map_err(|e| format!("leaf for {host}: {e}"))?; + let chain = vec![leaf.der().clone(), self.ca.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) + } + + /// Takes over a CONNECT to a bound host: answers it, speaks TLS to the + /// command as `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, host: &str, port: u16) { + let config = match self.leaf_for(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(host, 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)); + let Some((request_line, headers)) = read_head(&mut reader) else { return }; + let target = request_line.split_whitespace().nth(1).unwrap_or("/").to_string(); + let path = target.split(['?', '#']).next().unwrap_or("/").to_string(); + let head = format!("{}\r\n{}\r\n", request_line, headers.join("\r\n")); + match rewrite_head(&self.credentials, &head, host, port, &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"); + close(reader.get_mut()); + audit_log(audit, &Decision::new(host, port, "deny", format!("credential {name} is not bound to {host}{path}: the request was not sent"))); + } + Rewrite::Send(head, used) => { + if used.is_empty() { + audit_log(audit, &Decision::new(host, port, "allow", format!("opened for its credentials; {path} carried none"))); + } + for name in &used { + audit_log(audit, &Decision::new(host, port, "substitute", format!("credential {name} put on for {path}"))); + } + if let Err(reason) = self.forward(&mut reader, &head, host, port) { + let _ = reader.get_mut().write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + audit_log(audit, &Decision::new(host, port, "error", reason)); + } + close(reader.get_mut()); + } + } + } + + /// Sends the rewritten head and the body after it to the real server, then + /// the whole response back. + fn forward(&self, client: &mut BufReader, head: &str, host: &str, port: u16) -> Result<(), String> { + let upstream = match dial(host, port) { + Ok(stream) => stream, + Err(Dial::Blocked(reason)) | Err(Dial::Failed(reason)) => return Err(reason), + }; + let _ = upstream.set_read_timeout(Some(Duration::from_secs(300))); + let name = ServerName::try_from(host.to_string()).map_err(|e| format!("{host}: {e}"))?; + 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}"))?; + let mut buffer = [0u8; 16384]; + loop { + match server.read(&mut buffer) { + Ok(0) => break, + Ok(n) => client.get_mut().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 => break, + Err(e) => return Err(format!("upstream read: {e}")), + } + } + Ok(()) + } +} + +/// 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) +} + +fn make_ca() -> Result<(rcgen::Certificate, rcgen::KeyPair), String> { + 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 ca = params.self_signed(&key).map_err(|e| format!("CA: {e}"))?; + Ok((ca, key)) +} + +/// 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). +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. +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") { + for cert in CertificateDer::pem_file_iter(&path).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))? { + 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}"))?; + } + } + 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)) +} + +/// 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 request_line = lines.next().unwrap_or(""); + let mut out = format!("{request_line}\r\n"); + let mut framing = Framing::None; + for line in lines { + let Some((name, value)) = line.split_once(':') else { continue }; + let lower = name.trim().to_ascii_lowercase(); + match lower.as_str() { + "connection" | "proxy-connection" | "keep-alive" | "expect" | "proxy-authorization" => continue, + "transfer-encoding" if value.to_ascii_lowercase().contains("chunked") => framing = Framing::Chunked, + "content-length" if framing != Framing::Chunked => framing = Framing::Length(value.trim().parse().unwrap_or(0)), + _ => {} + } + out.push_str(line); + out.push_str("\r\n"); + } + out.push_str("Connection: close\r\n\r\n"); + (out, 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(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "body shorter than Content-Length")) } else { Ok(()) } + } + Framing::Chunked => loop { + let mut size_line = String::new(); + if from.read_line(&mut size_line)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "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 { + // Trailers, then the blank line that ends them. + 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(()); + } + } + } + std::io::copy(&mut from.take(size + 2), to)?; + }, + } +} + +/// 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(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => return None, + Ok(_) if line == "\r\n" || line == "\n" => return Some((request_line.trim_end().to_string(), headers)), + Ok(n) => { + 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(); +} + +#[cfg(test)] +mod tests { + 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(), + } + } + + #[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, "api.example.com", 443, "/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, "api.example.com", 443, "/admin"), Rewrite::Refuse("KEY".into())); + assert_eq!(rewrite_head(&creds, head, "evil.example.com", 443, "/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, "api.example.com", 443, "/"), 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\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/http_proxy.rs b/native/http_proxy.rs index f83a4f7..dd0b850 100644 --- a/native/http_proxy.rs +++ b/native/http_proxy.rs @@ -8,6 +8,7 @@ //! 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, Requested}; use crate::locking::locked; use crate::proxy_egress::{basic_credential, dial, mint_token, Dial}; use crate::proxy_audit::{audit_log, Decision}; @@ -33,11 +34,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 +55,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) { @@ -95,7 +102,10 @@ fn handle_connection(client: TcpStream, policy: Arc, audit_path: Ar 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(&host, port) => broker.intercept(client_for_write, &audit_path, &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); @@ -188,16 +198,38 @@ fn tunnel(mut client: TcpStream, audit_path: &Option, host: &str, port: /// 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()) { + let requested: Vec = match serde_json::from_str(credentials_json.as_ref()) { + Ok(requested) => requested, + Err(e) => return error_json(&format!("credentials: {e}")), + }; + let broker = if requested.is_empty() { + None + } else { + match Broker::new(requested) { + Ok(broker) => Some(Arc::new(broker)), + Err(reason) => return error_json(&reason), + } + }; + let mut policy = match requested_policy(allow_json.as_ref(), deny_json.as_ref(), broker.is_some()) { Ok(policy) => policy, Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), }; + // A credential bound to a host the allow-list leaves out would be handed + // in for nothing; binding it says the host is meant to be reached. + if let (ProxyMode::Allow, Some(broker)) = (policy.mode, &broker) { + policy.patterns.extend(broker.hosts()); + } let listener = match loopback_listener() { Ok(listener) => listener, Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), @@ -209,28 +241,43 @@ 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 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,6 +318,9 @@ 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 diff --git a/native/proxy_egress.rs b/native/proxy_egress.rs index 2928a6d..739f0e2 100644 --- a/native/proxy_egress.rs +++ b/native/proxy_egress.rs @@ -79,6 +79,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 +98,16 @@ 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 } + }) +} 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..a272baf 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(with_credential(opts, next)), "--read-policy" => valued({...opts, read_policy: next}), "--allow-root" => flag({...opts, allow_root: true}), "--no-net" => flag({...opts, no_net: true}), @@ -178,6 +183,15 @@ fn with_env(opts: Options, text: String) -> Options = none => opts, } +// --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 with_credential(opts: Options, text: String) -> Options = + match util.split_at_eq(text) { + some(kv) => {...opts, credentials: opts.credentials + [{name: kv.key, from_env: kv.key, + hosts: string.split(kv.val, ",") |> list.map(string.trim) |> list.filter((h) => h != "")}]}, + none => opts, + } + fn with_secret(opts: Options, text: String) -> Options = match util.split_at_eq(text) { some(kv) => {...opts, secrets: opts.secrets + [kv]}, @@ -270,6 +284,11 @@ fn line_when(present: Bool, line: String) -> List[String] = fn policy_toml(opts: Options) -> String = { let mounts = if list.len(opts.preopen_dirs) > 0 then opts.preopen_dirs else ["."] let proxy_on = list.len(opts.proxy_allow) > 0 or list.len(opts.proxy_deny) > 0 or opts.proxy_audit != "" + // Where each credential goes is policy and is saved; its value is read from + // the named variable when the run starts, never written here. + let credential_tables = opts.credentials |> list.flat_map((c) => + ["", "[[credentials]]", "name = " + toml_quote(c.name), "from = { env = " + toml_quote(c.from_env) + " }", + "hosts = " + toml_strings(c.hosts)]) 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]", @@ -294,6 +313,7 @@ fn policy_toml(opts: Options) -> String = { + 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)) + + credential_tables + 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..82d2c88 100644 --- a/src/proxy.almd +++ b/src/proxy.almd @@ -3,12 +3,26 @@ 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 + +// 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 +32,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 +60,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 +70,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(()) 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/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 From 83487ab8ceef8ca73aa2500dbaac2f816f61b4fc Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Mon, 28 Sep 2026 10:09:51 +0900 Subject: [PATCH 2/2] Split the credential broker by concern, back to grade A CI's code-quality floor (90) failed at 89: credential_broker.rs was one 488-line file with a deeply nested body copier and five-parameter functions, http_proxy.rs had passed 300 lines, and so had cli.almd. - credential_broker.rs keeps the credentials and where each may go; credential_broker/tls.rs makes the run's CA, its leaves, the trust bundle and the upstream verifier; credential_broker/exchange.rs carries one intercepted request; the unit tests move to credential_broker/tests.rs. A Target (host, port) replaces the separate arguments. - The tunnel and wt_is_host_allowed move beside the dial in proxy_egress.rs; the proxy's setup with credentials is one function. - The TOML quoting helpers move to util.almd; --credential parsing and the saved [[credentials]] tables to proxy.almd. No behaviour change: 163 Almide tests, 5 broker unit tests, the whole integration suite and escapes.py pass as before; codopsy grades 90 (A). Co-Authored-By: Claude Opus 5.5 (1M context) --- native/credential_broker.rs | 417 +++------------------------ native/credential_broker/exchange.rs | 211 ++++++++++++++ native/credential_broker/tests.rs | 63 ++++ native/credential_broker/tls.rs | 142 +++++++++ native/http_proxy.rs | 95 ++---- native/proxy_egress.rs | 54 +++- src/cli.almd | 54 +--- src/proxy.almd | 17 ++ src/util.almd | 8 + 9 files changed, 578 insertions(+), 483 deletions(-) create mode 100644 native/credential_broker/exchange.rs create mode 100644 native/credential_broker/tests.rs create mode 100644 native/credential_broker/tls.rs diff --git a/native/credential_broker.rs b/native/credential_broker.rs index 95a842a..e5a7144 100644 --- a/native/credential_broker.rs +++ b/native/credential_broker.rs @@ -15,21 +15,16 @@ //! as before, encrypted end to end and not read: a placeholder sent there is //! only a random string. //! -//! 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. +//! 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_audit::{audit_log, Decision}; -use crate::proxy_egress::{dial, mint_token, Dial}; -use rustls::pki_types::pem::PemObject; -use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; +use crate::proxy_egress::mint_token; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +mod exchange; +mod tls; /// Where one credential may go: a host pattern (`*.example.com` allowed), the /// port, and a path prefix ("" for any path). @@ -49,6 +44,13 @@ pub(crate) struct Credential { 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. @@ -72,18 +74,22 @@ pub(crate) fn parse_binding(text: &str) -> Result { } impl Binding { - fn covers(&self, host: &str, port: u16, path: &str) -> bool { - crate::http_proxy::host_matches(host, &self.host) && self.port == port && path_under(path, &self.path) + 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 `path` (a request target, query and all) is `prefix` or below it: -/// `/v1` covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`. -fn path_under(target: &str, prefix: &str) -> bool { +/// 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 = target.split(['?', '#']).next().unwrap_or(""); + let path = request_target.split(['?', '#']).next().unwrap_or(""); path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/')) } @@ -101,14 +107,11 @@ pub(crate) enum Rewrite { /// 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, host: &str, port: u16, target: &str) -> Rewrite { +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 { - if !out.contains(&credential.placeholder) { - continue; - } - if !credential.bindings.iter().any(|binding| binding.covers(host, port, target)) { + 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); @@ -117,18 +120,17 @@ pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, host: &str, p Rewrite::Send(out, used) } -/// The CA made for one run, and the leaf certificates it has signed so far. +/// The run's credentials, its CA, and the leaf certificates signed so far. pub(crate) struct Broker { pub(crate) credentials: Vec, - ca: rcgen::Certificate, - ca_key: rcgen::KeyPair, + 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, - pub(crate) ca_path: String, - pub(crate) bundle_path: Option, + ca_path: String, + bundle_path: Option, } /// One credential as the caller hands it over: the value read from where the @@ -140,44 +142,32 @@ pub(crate) struct Requested { hosts: Vec, } -fn provider() -> Arc { - Arc::new(rustls::crypto::ring::default_provider()) +/// 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. - pub(crate) fn new(requested: Vec) -> Result { + fn new(requested: Vec) -> Result { let credentials = merged(requested)?; - let (ca, ca_key) = make_ca()?; + 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 = format!("{dir}/ca.pem"); - std::fs::write(&ca_path, ca.pem()).map_err(|e| format!("cannot write {ca_path}: {e}"))?; - // 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 the command reaches - // would stop verifying. - let bundle_path = match system_roots() { - Some(roots) => { - let path = format!("{dir}/bundle.pem"); - let mut bundle = roots; - if !bundle.ends_with('\n') { - bundle.push('\n'); - } - bundle.push_str(&ca.pem()); - std::fs::write(&path, bundle).map_err(|e| format!("cannot write {path}: {e}"))?; - Some(path) - } - None => None, - }; - Ok(Broker { credentials, ca, ca_key, leaves: Mutex::new(HashMap::new()), upstream: upstream_config()?, dir, ca_path, bundle_path }) + 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, host: &str, port: u16) -> bool { - self.credentials.iter().any(|c| c.bindings.iter().any(|b| crate::http_proxy::host_matches(host, &b.host) && b.port == port)) + 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 @@ -207,104 +197,10 @@ impl Broker { if let Some(config) = leaves.get(host) { return Ok(config.clone()); } - let config = Arc::new(self.mint_leaf(host)?); + let config = Arc::new(self.ca.server_config_for(host)?); leaves.insert(host.to_string(), config.clone()); Ok(config) } - - fn mint_leaf(&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.ca, &self.ca_key).map_err(|e| format!("leaf for {host}: {e}"))?; - let chain = vec![leaf.der().clone(), self.ca.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) - } - - /// Takes over a CONNECT to a bound host: answers it, speaks TLS to the - /// command as `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, host: &str, port: u16) { - let config = match self.leaf_for(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(host, 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)); - let Some((request_line, headers)) = read_head(&mut reader) else { return }; - let target = request_line.split_whitespace().nth(1).unwrap_or("/").to_string(); - let path = target.split(['?', '#']).next().unwrap_or("/").to_string(); - let head = format!("{}\r\n{}\r\n", request_line, headers.join("\r\n")); - match rewrite_head(&self.credentials, &head, host, port, &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"); - close(reader.get_mut()); - audit_log(audit, &Decision::new(host, port, "deny", format!("credential {name} is not bound to {host}{path}: the request was not sent"))); - } - Rewrite::Send(head, used) => { - if used.is_empty() { - audit_log(audit, &Decision::new(host, port, "allow", format!("opened for its credentials; {path} carried none"))); - } - for name in &used { - audit_log(audit, &Decision::new(host, port, "substitute", format!("credential {name} put on for {path}"))); - } - if let Err(reason) = self.forward(&mut reader, &head, host, port) { - let _ = reader.get_mut().write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - audit_log(audit, &Decision::new(host, port, "error", reason)); - } - close(reader.get_mut()); - } - } - } - - /// Sends the rewritten head and the body after it to the real server, then - /// the whole response back. - fn forward(&self, client: &mut BufReader, head: &str, host: &str, port: u16) -> Result<(), String> { - let upstream = match dial(host, port) { - Ok(stream) => stream, - Err(Dial::Blocked(reason)) | Err(Dial::Failed(reason)) => return Err(reason), - }; - let _ = upstream.set_read_timeout(Some(Duration::from_secs(300))); - let name = ServerName::try_from(host.to_string()).map_err(|e| format!("{host}: {e}"))?; - 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}"))?; - let mut buffer = [0u8; 16384]; - loop { - match server.read(&mut buffer) { - Ok(0) => break, - Ok(n) => client.get_mut().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 => break, - Err(e) => return Err(format!("upstream read: {e}")), - } - } - Ok(()) - } } /// Credentials by name: two bindings given for one name are one credential @@ -325,224 +221,5 @@ fn merged(requested: Vec) -> Result, String> { Ok(out) } -fn make_ca() -> Result<(rcgen::Certificate, rcgen::KeyPair), String> { - 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 ca = params.self_signed(&key).map_err(|e| format!("CA: {e}"))?; - Ok((ca, key)) -} - -/// 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). -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. -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") { - for cert in CertificateDer::pem_file_iter(&path).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))? { - 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}"))?; - } - } - 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)) -} - -/// 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 request_line = lines.next().unwrap_or(""); - let mut out = format!("{request_line}\r\n"); - let mut framing = Framing::None; - for line in lines { - let Some((name, value)) = line.split_once(':') else { continue }; - let lower = name.trim().to_ascii_lowercase(); - match lower.as_str() { - "connection" | "proxy-connection" | "keep-alive" | "expect" | "proxy-authorization" => continue, - "transfer-encoding" if value.to_ascii_lowercase().contains("chunked") => framing = Framing::Chunked, - "content-length" if framing != Framing::Chunked => framing = Framing::Length(value.trim().parse().unwrap_or(0)), - _ => {} - } - out.push_str(line); - out.push_str("\r\n"); - } - out.push_str("Connection: close\r\n\r\n"); - (out, 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(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "body shorter than Content-Length")) } else { Ok(()) } - } - Framing::Chunked => loop { - let mut size_line = String::new(); - if from.read_line(&mut size_line)? == 0 { - return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "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 { - // Trailers, then the blank line that ends them. - 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(()); - } - } - } - std::io::copy(&mut from.take(size + 2), to)?; - }, - } -} - -/// 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(); - match reader.read_line(&mut line) { - Ok(0) | Err(_) => return None, - Ok(_) if line == "\r\n" || line == "\n" => return Some((request_line.trim_end().to_string(), headers)), - Ok(n) => { - 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(); -} - #[cfg(test)] -mod tests { - 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(), - } - } - - #[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, "api.example.com", 443, "/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, "api.example.com", 443, "/admin"), Rewrite::Refuse("KEY".into())); - assert_eq!(rewrite_head(&creds, head, "evil.example.com", 443, "/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, "api.example.com", 443, "/"), 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\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)); - } -} +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 dd0b850..84d5103 100644 --- a/native/http_proxy.rs +++ b/native/http_proxy.rs @@ -8,9 +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, Requested}; +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}; @@ -84,18 +86,6 @@ 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 }; @@ -103,7 +93,7 @@ fn handle_connection(client: TcpStream, policy: Arc, audit_path: Ar let Some((request_line, headers)) = read_request(&mut reader) else { return }; match connect_target(&request_line, &headers, &policy) { Ok((host, port)) => match &policy.broker { - Some(broker) if broker.intercepts(&host, port) => broker.intercept(client_for_write, &audit_path, &host, port), + 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)) => { @@ -172,29 +162,6 @@ 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). @@ -209,27 +176,10 @@ pub fn wt_proxy_start( audit_path: impl AsRef, credentials_json: impl AsRef, ) -> String { - let requested: Vec = match serde_json::from_str(credentials_json.as_ref()) { - Ok(requested) => requested, - Err(e) => return error_json(&format!("credentials: {e}")), - }; - let broker = if requested.is_empty() { - None - } else { - match Broker::new(requested) { - Ok(broker) => Some(Arc::new(broker)), - Err(reason) => return error_json(&reason), - } - }; - let mut policy = match requested_policy(allow_json.as_ref(), deny_json.as_ref(), broker.is_some()) { - 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), }; - // A credential bound to a host the allow-list leaves out would be handed - // in for nothing; binding it says the host is meant to be reached. - if let (ProxyMode::Allow, Some(broker)) = (policy.mode, &broker) { - policy.patterns.extend(broker.hosts()); - } let listener = match loopback_listener() { Ok(listener) => listener, Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), @@ -264,6 +214,19 @@ 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 @@ -325,19 +288,3 @@ pub fn wt_proxy_stop(handle: i64) -> i64 { 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 739f0e2..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, @@ -111,3 +113,53 @@ fn test_upstream(host: &str) -> Option { 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/src/cli.almd b/src/cli.almd index a272baf..ea19b81 100644 --- a/src/cli.almd +++ b/src/cli.almd @@ -134,7 +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(with_credential(opts, 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}), @@ -183,15 +183,6 @@ fn with_env(opts: Options, text: String) -> Options = none => opts, } -// --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 with_credential(opts: Options, text: String) -> Options = - match util.split_at_eq(text) { - some(kv) => {...opts, credentials: opts.credentials + [{name: kv.key, from_env: kv.key, - hosts: string.split(kv.val, ",") |> list.map(string.trim) |> list.filter((h) => h != "")}]}, - none => opts, - } - fn with_secret(opts: Options, text: String) -> Options = match util.split_at_eq(text) { some(kv) => {...opts, secrets: opts.secrets + [kv]}, @@ -266,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 [] @@ -284,36 +267,31 @@ fn line_when(present: Bool, line: String) -> List[String] = fn policy_toml(opts: Options) -> String = { let mounts = if list.len(opts.preopen_dirs) > 0 then opts.preopen_dirs else ["."] let proxy_on = list.len(opts.proxy_allow) > 0 or list.len(opts.proxy_deny) > 0 or opts.proxy_audit != "" - // Where each credential goes is policy and is saved; its value is read from - // the named variable when the run starts, never written here. - let credential_tables = opts.credentials |> list.flat_map((c) => - ["", "[[credentials]]", "name = " + toml_quote(c.name), "from = { env = " + toml_quote(c.from_env) + " }", - "hosts = " + toml_strings(c.hosts)]) 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)) - + credential_tables + + 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/proxy.almd b/src/proxy.almd index 82d2c88..cc91386 100644 --- a/src/proxy.almd +++ b/src/proxy.almd @@ -6,6 +6,7 @@ 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 @@ -110,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/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), ", ") + "]"