From 0d78711109779666eaa1ee0611b3ecc4fb249439 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Thu, 24 Sep 2026 17:41:46 +0200 Subject: [PATCH 1/2] Let the configured integration proxy live on a private network `ProxyOrigin` only exempted a literal loopback address or `localhost`, so `--integration-proxy-url http://host.docker.internal:8787` (192.168.65.x on Docker Desktop, 172.17.0.1 on Linux) or a LAN proxy was refused. Exactly the configured scheme, host and port may now resolve to loopback, private, CGNAT or ULA addresses. The name is resolved once per request and those checked addresses are pinned into the client. Link-local and metadata addresses stay refused even for the proxy (and a literal one is refused at startup), and so do credentials in the URL. Every other destination keeps all the checks. Adds a `Resolve` seam so tests can make a name resolve to a private address, and an `it plugin_proxy` test that runs a JS plugin through `POST /plugin-run` on a real server and checks that `ctx.http` reaches a stub proxy as `GET /proxy/conn-1/demo/items`, v2-signed by the installation's node agent, and that an undeclared platform is refused. Refs #1700 Co-Authored-By: Claude Opus 5.5 --- TESTING_COVERAGE.md | 13 +- docs/src/plugins/creating-plugins.md | 1 + server/src/config.rs | 8 +- server/src/plugins/egress.rs | 331 +++++++++++++++++++++++--- server/tests/it/main.rs | 1 + server/tests/it/plugin_proxy.rs | 334 +++++++++++++++++++++++++++ 6 files changed, 656 insertions(+), 32 deletions(-) create mode 100644 server/tests/it/plugin_proxy.rs diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md index eec866aa5b..c8c594374e 100644 --- a/TESTING_COVERAGE.md +++ b/TESTING_COVERAGE.md @@ -221,7 +221,18 @@ that an installation with no app agent on this node is refused before connecting, and that other loopback origins stay refused even when a manifest declares them. `plugins::egress` tests pin the exception to exactly the configured origin (another port, the other scheme, another loopback address, -`localhost` for `127.0.0.1`, and credentials in the URL are all refused). +`localhost` for `127.0.0.1`, and credentials in the URL are all refused). They +also use a table resolver to check that a proxy *name* may resolve to a +private, CGNAT, ULA or loopback address (`host.docker.internal`, a LAN host), +resolved once and pinned, while another name, port or scheme resolving to the +same address is refused, and link-local/metadata is refused even when it is +the configured proxy. End to end, `it plugin_proxy` starts a real server with +`--integration-proxy-url` pointing at a loopback stub, pins and installs a JS +release over HTTP, runs it through `POST /plugin-run`, and checks that +`ctx.http("atomic-proxy:/demo/items")` arrives as `GET +/proxy/conn-1/demo/items` with a v2 signature from the installation's node +agent, that the plugin gets the stub's response, and that an undeclared +platform is refused before connecting. `app_endpoints_test::an_active_installation_reports_its_agent_on_this_node` checks `GET /app-agent` reports the identity activation mints for a JS Installation. Not covered: a real integration proxy (atomic-plugins#122) diff --git a/docs/src/plugins/creating-plugins.md b/docs/src/plugins/creating-plugins.md index bf8530f254..7a13119653 100644 --- a/docs/src/plugins/creating-plugins.md +++ b/docs/src/plugins/creating-plugins.md @@ -127,6 +127,7 @@ Every field except `schemaVersion` is optional; unknown fields and malformed dec - `secrets`, `operations`, `actions`: as in schema version 1. Secrets name an exact origin a credential may be sent to; operations are exact endpoints with an `effect` of `read` or `write`; actions reference operations. - `network.origins`: exact origins (no wildcards, paths or ports beyond the origin) for packages that call the host `fetch` without an operation id. It never widens what `operations` grant. - `proxy`: integration-proxy platforms the plugin uses, for example `["clockify"]`, also accepted in schema version 1. The plugin calls `ctx.http` with a proxy-relative URL such as `atomic-proxy:/clockify/api/v1/user`, and the operation that admits it is declared with that URL too. The server resolves it to `{--integration-proxy-url}/proxy/{connection_id}/clockify/api/v1/user`, taking the connection id from the Installation's `integrationConnections` (also passed to the plugin as `ctx.connections`), and signs it as the node's agent for the installation. A request is refused when the platform is not declared, when no connection is delegated for it, or when the node has no proxy configured. Calling the proxy by its absolute URL still works but is deprecated. + The proxy the operator configures with `--integration-proxy-url` (`ATOMIC_INTEGRATION_PROXY_URL`) may live on this machine or a private network, such as `http://host.docker.internal:8787` or `http://proxy.lan:8787`: exactly that scheme, host and port may resolve to loopback, private, carrier-grade NAT or IPv6 unique-local addresses, which every other plugin destination is refused. The name is resolved once per request and the host connects to exactly the addresses it checked. Link-local and cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`) are refused even for the proxy, and credentials in the URL are refused. - `configSchema`, `defaultConfig`: objects, as in `plugin.json`. - `name`, `namespace`, `version`, `description`, `author`: metadata. `name` and `namespace` must be safe path segments. diff --git a/server/src/config.rs b/server/src/config.rs index 210497260b..d16126d2c2 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -90,8 +90,12 @@ pub struct Opts { /// The integration proxy's origin, e.g. https://localthought.io or, for a proxy on this /// machine, http://localhost:8080 — exactly the proxy's own BASE_URL. A server-side plugin's /// `ctx.http` requests to this origin are signed with this node's app agent for its - /// installation (Atomic v2 request signatures), and a loopback origin is let through the - /// public-address check for exactly this scheme, host and port. Omit to configure none. + /// installation (Atomic v2 request signatures). Exactly this scheme, host and port may be + /// on loopback or a private network (e.g. http://host.docker.internal:8787 or + /// http://proxy.lan:8787), which every other plugin destination is refused; the name is + /// resolved once per request and the checked addresses are the ones connected to. + /// Link-local and cloud-metadata addresses (169.254.0.0/16, fe80::/10) are refused even + /// here. Omit to configure none. #[clap(long, env = "ATOMIC_INTEGRATION_PROXY_URL")] pub integration_proxy_url: Option, diff --git a/server/src/plugins/egress.rs b/server/src/plugins/egress.rs index 99c87a8eed..f28605047d 100644 --- a/server/src/plugins/egress.rs +++ b/server/src/plugins/egress.rs @@ -182,8 +182,40 @@ pub async fn refuse_url(url: &str) -> Option { None } +/// How the host turns a name into addresses. The system resolver in +/// production; tests pass one that answers from a table, so a name can +/// "resolve" to a private address without touching real DNS. +pub trait Resolve: Sync { + fn resolve<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> futures::future::BoxFuture<'a, std::io::Result>>; +} + +/// The operating system's resolver, via `tokio::net::lookup_host`. +pub struct SystemResolver; + +impl Resolve for SystemResolver { + fn resolve<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> futures::future::BoxFuture<'a, std::io::Result>> { + Box::pin(async move { Ok(tokio::net::lookup_host((host, port)).await?.collect()) }) + } +} + /// Resolve once and return only checked destinations for the host HTTP client. pub async fn checked_addresses(url: &url::Url) -> Result, String> { + checked_addresses_with(url, &SystemResolver).await +} + +/// [checked_addresses] with the resolver given. +pub async fn checked_addresses_with( + url: &url::Url, + resolver: &dyn Resolve, +) -> Result, String> { if !matches!(url.scheme(), "http" | "https") { return Err("only HTTP and HTTPS are fetchable".into()); } @@ -192,10 +224,10 @@ pub async fn checked_addresses(url: &url::Url) -> Result = tokio::net::lookup_host((host.trim_matches(['[', ']']), port)) + let addresses = resolver + .resolve(host.trim_matches(['[', ']']), port) .await - .map_err(|e| format!("could not resolve {host}: {e}"))? - .collect(); + .map_err(|e| format!("could not resolve {host}: {e}"))?; if addresses.is_empty() { return Err("host resolved to no addresses".into()); } @@ -209,26 +241,50 @@ pub async fn checked_addresses(url: &url::Url) -> Result Option { + match refuse_address(addr)? { + Refusal::Loopback | Refusal::Private | Refusal::UniqueLocal => None, + Refusal::MappedV4("loopback" | "private") => None, + refusal => Some(refusal), + } +} + /// The integration proxy this node is configured with (ontola/atomic-plugins#54, /// decisions 8 and 12). /// -/// It is the one destination that may be on loopback: a proxy on the same -/// machine, even one embedded in the same executable, is reached over HTTP so -/// every check the proxy runs still runs. The exception is for exactly this -/// origin, scheme and port included, and the host connects to the loopback -/// address itself rather than resolving the name, so DNS cannot redirect it. +/// It is the one destination that may be on loopback or a private network: a +/// proxy on the same machine (even one embedded in the same executable), in a +/// sibling container or on the operator's LAN is reached over HTTP so every +/// check the proxy runs still runs. The exception is for exactly this origin, +/// scheme and port included, and never covers link-local or metadata +/// addresses ([refuse_proxy_address]). A literal address or `localhost` is +/// connected to without a lookup; any other name is resolved once per +/// request, and exactly the addresses that were checked are the ones +/// connected to, so DNS cannot redirect it between the check and the connect. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProxyOrigin { origin: String, - /// For a proxy on this machine, the addresses to connect to. `None` for a - /// proxy elsewhere, which gets the ordinary checks like any other host. - loopback: Option>, + /// The addresses to connect to when they are known without a lookup: a + /// literal address, or loopback for `localhost`. `None` for a name, which + /// is resolved per request. + fixed: Option>, } impl ProxyOrigin { /// An origin: `http` or `https`, a host, an optional port, and nothing /// else. A path, query or credentials would suggest the proxy is only part /// of that origin, and the exception must never cover more than the proxy. + /// A literal link-local, unspecified or multicast address is refused here, + /// at startup, rather than on the first request. pub fn parse(raw: &str) -> Result { let url = url::Url::parse(raw.trim()) .map_err(|e| format!("integration proxy URL {raw:?} is not a URL: {e}"))?; @@ -252,16 +308,23 @@ impl ProxyOrigin { .port_or_known_default() .ok_or("integration proxy URL has no port")?; let at = |ip: IpAddr| std::net::SocketAddr::new(ip, port); - let loopback = match url.host() { - Some(url::Host::Ipv4(ip)) if ip.is_loopback() => Some(vec![at(ip.into())]), - Some(url::Host::Ipv6(ip)) if ip.is_loopback() => Some(vec![at(ip.into())]), + let fixed = match url.host() { + Some(url::Host::Ipv4(ip)) => Some(vec![at(ip.into())]), + Some(url::Host::Ipv6(ip)) => Some(vec![at(ip.into())]), Some(url::Host::Domain(name)) if name.eq_ignore_ascii_case("localhost") => Some(vec![ at(Ipv4Addr::LOCALHOST.into()), at(Ipv6Addr::LOCALHOST.into()), ]), _ => None, }; - Ok(Self { origin, loopback }) + for address in fixed.iter().flatten() { + if let Some(refusal) = refuse_proxy_address(address.ip()) { + return Err(format!( + "integration proxy URL {raw:?} is a refused address ({refusal:?}); link-local and metadata addresses are never a proxy" + )); + } + } + Ok(Self { origin, fixed }) } /// `scheme://host[:port]`, as [origin_of] writes it. @@ -275,23 +338,51 @@ impl ProxyOrigin { } } -/// [checked_addresses], except for exactly the configured proxy on this -/// machine, which gets its loopback addresses without a lookup. +/// [checked_addresses], except for exactly the configured proxy, which may be +/// on loopback or a private network (see [ProxyOrigin]). pub async fn destination_addresses( url: &url::Url, proxy: Option<&ProxyOrigin>, ) -> Result, String> { - if let Some(ProxyOrigin { - loopback: Some(addresses), - .. - }) = proxy.filter(|proxy| proxy.is_target_of(url)) - { - if !url.username().is_empty() || url.password().is_some() { - return Err("credentials belong in host-owned secrets, not URLs".into()); + destination_addresses_with(url, proxy, &SystemResolver).await +} + +/// [destination_addresses] with the resolver given. The addresses returned +/// are the ones to connect to: the caller pins them into its client and never +/// resolves the name again. +pub async fn destination_addresses_with( + url: &url::Url, + proxy: Option<&ProxyOrigin>, + resolver: &dyn Resolve, +) -> Result, String> { + let Some(proxy) = proxy.filter(|proxy| proxy.is_target_of(url)) else { + return checked_addresses_with(url, resolver).await; + }; + if !url.username().is_empty() || url.password().is_some() { + return Err("credentials belong in host-owned secrets, not URLs".into()); + } + let host = url.host_str().ok_or("URL has no host")?; + let addresses = match &proxy.fixed { + Some(addresses) => addresses.clone(), + None => { + let port = url.port_or_known_default().ok_or("URL has no port")?; + resolver + .resolve(host, port) + .await + .map_err(|e| format!("could not resolve {host}: {e}"))? + } + }; + if addresses.is_empty() { + return Err(format!("{host} resolved to no addresses")); + } + for address in &addresses { + if let Some(refusal) = refuse_proxy_address(address.ip()) { + return Err(format!( + "the integration proxy {host} resolves to a refused address ({refusal:?})" + )); } - return Ok(addresses.clone()); } - checked_addresses(url).await + Ok(addresses) } /// The `scheme://host[:port]` of a URL, which is what an origin allowlist and a @@ -616,9 +707,7 @@ mod tests { } #[tokio::test] - async fn a_proxy_elsewhere_gets_no_exception() { - // A public proxy passes the ordinary checks on its own; configuring - // one must not turn a name that resolves to loopback into an exception. + async fn a_proxy_name_that_does_not_resolve_is_refused() { let proxy = ProxyOrigin::parse("http://proxy.invalid:7070").unwrap(); assert!( destination_addresses(&url("http://proxy.invalid:7070/x"), Some(&proxy)) @@ -626,4 +715,188 @@ mod tests { .is_err() ); } + + /// Answers from a table, and counts the lookups, so a test can make a + /// name resolve to a private address and see that it was resolved once. + struct Table { + entries: Vec<(&'static str, Vec)>, + lookups: std::sync::atomic::AtomicUsize, + } + + impl Table { + fn new(entries: &[(&'static str, &[&str])]) -> Self { + Self { + entries: entries + .iter() + .map(|(name, ips)| (*name, ips.iter().map(|ip| ip.parse().unwrap()).collect())) + .collect(), + lookups: Default::default(), + } + } + } + + impl Resolve for Table { + fn resolve<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> futures::future::BoxFuture<'a, std::io::Result>> { + self.lookups + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // A literal resolves to itself, as it does with the system resolver. + let literal = host.parse::().ok().map(|ip| vec![ip]); + let found = literal + .or_else(|| { + self.entries + .iter() + .find(|(name, _)| *name == host) + .map(|(_, ips)| ips.clone()) + }) + .map(|ips| { + ips.into_iter() + .map(|ip| std::net::SocketAddr::new(ip, port)) + .collect() + }); + Box::pin(async move { found.ok_or_else(|| std::io::Error::other("no such host")) }) + } + } + + #[tokio::test] + async fn a_configured_proxy_name_may_resolve_to_a_private_address() { + // Docker Desktop, Docker on Linux, a LAN, a Tailscale-style CGNAT + // address and a v6 ULA: all places a self-hosted proxy lives. + let table = Table::new(&[ + ("host.docker.internal", &["192.168.65.254"]), + ("proxy.lan", &["192.168.1.10"]), + ("bridge.internal", &["172.17.0.1"]), + ("tail.net", &["100.100.1.2"]), + ("ula.lan", &["fd00::10"]), + ("self.lan", &["127.0.0.1", "::1"]), + ]); + for (origin, path) in [ + ("http://host.docker.internal:8787", "/proxy/c/github/x?y=1"), + ("http://proxy.lan:8787", "/runtimes"), + ("https://bridge.internal", "/x"), + ("http://tail.net:8787", "/x"), + ("http://ula.lan:8787", "/x"), + ("http://self.lan:8787", "/x"), + ] { + let proxy = ProxyOrigin::parse(origin).unwrap(); + let before = table.lookups.load(std::sync::atomic::Ordering::SeqCst); + let addresses = + destination_addresses_with(&url(&format!("{origin}{path}")), Some(&proxy), &table) + .await + .unwrap_or_else(|e| panic!("{origin}: {e}")); + // Exactly what the one lookup answered: these are pinned into the + // client, which never resolves the name again. + assert!(!addresses.is_empty(), "{origin}"); + assert_eq!( + table.lookups.load(std::sync::atomic::Ordering::SeqCst), + before + 1, + "{origin} was resolved more than once" + ); + } + let proxy = ProxyOrigin::parse("http://proxy.lan:8787").unwrap(); + assert_eq!( + destination_addresses_with(&url("http://proxy.lan:8787/x"), Some(&proxy), &table) + .await + .unwrap(), + vec!["192.168.1.10:8787".parse().unwrap()] + ); + } + + #[tokio::test] + async fn the_private_exception_is_for_exactly_the_configured_origin() { + let table = Table::new(&[ + ("proxy.lan", &["192.168.1.10"]), + ("other.lan", &["192.168.1.10"]), + ("printer.lan", &["192.168.1.20"]), + ]); + let proxy = ProxyOrigin::parse("http://proxy.lan:8787").unwrap(); + for other in [ + // Another port on the proxy's host. + "http://proxy.lan:8788/x", + "http://proxy.lan/x", + // The other scheme. + "https://proxy.lan:8787/x", + // Another name for the very same address. + "http://other.lan:8787/x", + // The same address as a literal. + "http://192.168.1.10:8787/x", + // Another host on the same network. + "http://printer.lan:8787/x", + // Credentials, even to the proxy itself. + "http://u:p@proxy.lan:8787/x", + ] { + let result = destination_addresses_with(&url(other), Some(&proxy), &table).await; + assert!(result.is_err(), "{other} was let through: {result:?}"); + } + // With no proxy configured, the proxy's own name is refused too. + assert!( + destination_addresses_with(&url("http://proxy.lan:8787/x"), None, &table) + .await + .is_err() + ); + } + + #[tokio::test] + async fn metadata_is_never_a_proxy() { + for literal in [ + "http://169.254.169.254", + "http://169.254.169.254:80", + "http://[fe80::1]:8787", + "http://[::ffff:169.254.169.254]:8787", + "http://0.0.0.0:8787", + "http://224.0.0.1:8787", + ] { + assert!(ProxyOrigin::parse(literal).is_err(), "{literal}"); + } + // A name that resolves there is refused per request, including when + // only one of its answers is link-local. + let table = Table::new(&[ + ("metadata.lan", &["169.254.169.254"]), + ("mixed.lan", &["192.168.1.10", "169.254.169.254"]), + ("v6.lan", &["fe80::1"]), + ]); + for origin in [ + "http://metadata.lan", + "http://mixed.lan:8787", + "http://v6.lan:8787", + ] { + let proxy = ProxyOrigin::parse(origin).unwrap(); + let err = destination_addresses_with( + &url(&format!("{origin}/latest/meta-data/")), + Some(&proxy), + &table, + ) + .await + .unwrap_err(); + assert!(err.contains("LinkLocal"), "{origin}: {err}"); + } + } + + #[test] + fn a_private_literal_proxy_is_accepted() { + assert!(ProxyOrigin::parse("http://192.168.1.10:8787").is_ok()); + assert!(ProxyOrigin::parse("http://172.17.0.1:8787").is_ok()); + assert!(ProxyOrigin::parse("http://[fd00::10]:8787").is_ok()); + } + + #[tokio::test] + async fn a_private_literal_proxy_is_reached_at_that_address_without_a_lookup() { + let table = Table::new(&[]); + let proxy = ProxyOrigin::parse("http://172.17.0.1:8787").unwrap(); + assert_eq!( + destination_addresses_with(&url("http://172.17.0.1:8787/x"), Some(&proxy), &table) + .await + .unwrap(), + vec!["172.17.0.1:8787".parse().unwrap()] + ); + assert_eq!(table.lookups.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!( + destination_addresses_with(&url("http://172.17.0.2:8787/x"), Some(&proxy), &table) + .await + .is_err() + ); + } } diff --git a/server/tests/it/main.rs b/server/tests/it/main.rs index 9b0723e088..36ecd86e9c 100644 --- a/server/tests/it/main.rs +++ b/server/tests/it/main.rs @@ -14,6 +14,7 @@ mod history_attribution; mod iroh_pairing; mod loro_ephemeral_sync; mod multi_client_sync; +mod plugin_proxy; mod put_blob; mod rate_limit; mod replicate; diff --git a/server/tests/it/plugin_proxy.rs b/server/tests/it/plugin_proxy.rs new file mode 100644 index 0000000000..9c4139f6be --- /dev/null +++ b/server/tests/it/plugin_proxy.rs @@ -0,0 +1,334 @@ +//! A JS plugin's `ctx.http("atomic-proxy:/...")` reaches the integration +//! proxy, end to end: a real server started with `--integration-proxy-url`, a +//! release pinned and installed over HTTP, the plugin run through +//! `POST /plugin-run`, and a stub proxy on a real loopback socket that records +//! what arrived (#1700). +//! +//! Run: cargo test -p atomic-server --test it plugin_proxy + +use std::sync::{Arc, Mutex}; + +use atomic_lib::{ + agents::Agent, client::connected::Client, errors::AtomicResult, urls, Resource, Value, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::common::{start_server_with_args, wait_for_server}; + +const STUB_BODY: &str = r#"{"items":["a","b"]}"#; + +/// A stand-in for the integration proxy: answers every request with +/// [STUB_BODY] and keeps the raw request head. +async fn stub_proxy() -> (u16, Arc>>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let recorded = seen.clone(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let recorded = recorded.clone(); + tokio::spawn(async move { + let mut raw = Vec::new(); + let mut buf = [0u8; 4096]; + while !raw.windows(4).any(|w| w == b"\r\n\r\n") { + match socket.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => raw.extend_from_slice(&buf[..n]), + } + } + recorded + .lock() + .unwrap() + .push(String::from_utf8_lossy(&raw).into_owned()); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{STUB_BODY}", + STUB_BODY.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + }); + } + }); + (port, seen) +} + +fn header<'a>(raw: &'a str, name: &str) -> Vec<&'a str> { + raw.lines() + .filter_map(|line| line.split_once(':')) + .filter(|(n, _)| n.trim().eq_ignore_ascii_case(name)) + .map(|(_, v)| v.trim()) + .collect() +} + +fn encode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +/// A signed request as a browser would make it (v1: over the URL). +async fn signed( + method: reqwest::Method, + url: &str, + agent: &Agent, + body: Option, +) -> serde_json::Value { + let mut req = reqwest::Client::new().request(method, url); + for (k, v) in atomic_lib::client::get_authentication_headers(url, agent).unwrap() { + req = req.header(k, v); + } + if let Some(body) = body { + req = req.json(&body); + } + let response = req.send().await.unwrap(); + let status = response.status(); + let text = response.text().await.unwrap(); + assert!(status.is_success(), "{url}: {status} {text}"); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{url}: {e}: {text}")) +} + +async fn create(client: &Client, parent: &str, props: Vec<(&str, Value)>) -> String { + let mut resource: Resource = client.new_resource(parent).unwrap(); + for (prop, value) in props { + resource.set_unsafe(prop.into(), value).unwrap(); + } + resource.save_remote(client.store()).await.unwrap() +} + +/// Declares platform `demo` and reads `atomic-proxy:/demo/items`; also tries +/// `other`, which it does not declare (a manifest cannot even declare an +/// operation on it). Returns what happened to both. +const SOURCE: &str = r#" +export const manifest = { + schemaVersion: 2, + proxy: ["demo"], + operations: [ + { id: "items", method: "GET", url: "atomic-proxy:/demo/items", effect: "read" }, + ], +}; +export function run(ctx) { + const res = ctx.http({ operation: "items", method: "GET", url: "atomic-proxy:/demo/items" }); + let refused = null; + try { + ctx.http({ method: "GET", url: "atomic-proxy:/other/items" }); + } catch (e) { + refused = String(e && e.message || e); + } + return { intents: [], problems: [], status: res.status, body: res.body, + connections: ctx.connections, refused }; +} +"#; + +#[tokio::test] +async fn a_plugin_reaches_the_integration_proxy_signed_as_its_node_agent() -> AtomicResult<()> { + let (stub_port, seen) = stub_proxy().await; + let proxy_origin = format!("http://127.0.0.1:{stub_port}"); + let port = start_server_with_args("plugin_proxy", &["--integration-proxy-url", &proxy_origin]); + wait_for_server(port).await; + let server = format!("http://localhost:{port}"); + + let client = Client::new(&server).await?; + let alice = client.new_agent("Alice").await?; + + // The drive's ontology names the property a draft keeps its source in; + // it lives in a drive of its own so the real one can point at it from + // its genesis. + let vocabulary = client.new_drive(&alice, "Vocabulary").await?; + let ontology = create( + &client, + &vocabulary, + vec![ + ( + urls::IS_A, + Value::ResourceArray(vec![urls::ONTOLOGY.into()]), + ), + (urls::SHORTNAME, Value::Slug("plugins".into())), + (urls::DESCRIPTION, Value::Markdown("Plugins".into())), + ], + ) + .await; + let source_prop = create( + &client, + &ontology, + vec![ + ( + urls::IS_A, + Value::ResourceArray(vec![urls::PROPERTY.into()]), + ), + (urls::SHORTNAME, Value::Slug("plugin-source".into())), + (urls::DESCRIPTION, Value::Markdown("plugin source".into())), + (urls::DATATYPE_PROP, Value::AtomicUrl(urls::MARKDOWN.into())), + ], + ) + .await; + let mut ontology_resource = client.get_resource(&ontology).await?; + ontology_resource.set_unsafe( + urls::PROPERTIES.into(), + Value::ResourceArray(vec![source_prop.as_str().into()]), + )?; + ontology_resource.save_remote(client.store()).await?; + + let mut drive = Resource::new("did:ad:placeholder".into()); + drive.set_unsafe( + urls::IS_A.into(), + Value::ResourceArray(vec![urls::DRIVE.into()]), + )?; + drive.set_name("Proxy drive")?; + for right in [urls::READ, urls::WRITE] { + drive.set_unsafe( + right.into(), + Value::ResourceArray(vec![alice.subject.to_string().into()]), + )?; + } + drive.set_unsafe( + urls::DEFAULT_ONTOLOGY.into(), + Value::AtomicUrl(ontology.as_str().into()), + )?; + let drive = drive.save_remote(client.store()).await?; + + // A draft, pinned as a private release, installed from that release. + let draft = create( + &client, + &drive, + vec![ + (urls::NAME, Value::String("Demo draft".into())), + (source_prop.as_str(), Value::Markdown(SOURCE.into())), + ], + ) + .await; + let pinned = signed( + reqwest::Method::POST, + &format!("{server}/plugin-release-pin"), + &alice, + Some(serde_json::json!({"drive": drive, "plugin": draft})), + ) + .await; + let release_id = pinned["id"].as_str().expect("a release id").to_string(); + let release = pinned["subject"].as_str().expect("a release").to_string(); + + let app_id = Agent::new(None)?.subject.to_string(); + let installation = create( + &client, + &drive, + vec![ + ( + urls::IS_A, + Value::ResourceArray(vec![urls::INSTALLATION.into()]), + ), + (urls::NAME, Value::String("demo".into())), + (urls::NAMESPACE, Value::String("test".into())), + ( + urls::RELEASE_PROP, + Value::AtomicUrl(release.as_str().into()), + ), + (urls::RELEASE_ID, Value::String(release_id)), + (urls::GRANTS, Value::Json(serde_json::json!([]))), + (urls::INSTALLATION_STATUS, Value::String("active".into())), + ( + urls::INTEGRATION_APP_AGENT, + Value::AtomicUrl(app_id.as_str().into()), + ), + ( + urls::INTEGRATION_CONNECTIONS, + Value::Json(serde_json::json!({"demo": "conn-1"})), + ), + ], + ) + .await; + + // The agent activation minted for this installation on this node. + let info = signed( + reqwest::Method::GET, + &format!( + "{server}/app-agent?drive={}&app={}", + encode(&drive), + encode(&installation) + ), + &alice, + None, + ) + .await; + let node_agent = info["agent"] + .as_str() + .unwrap_or_else(|| panic!("activation minted no agent: {info}")) + .to_string(); + + let ran = signed( + reqwest::Method::POST, + &format!("{server}/plugin-run"), + &alice, + Some(serde_json::json!({ + "drive": drive, + "plugin": installation, + "source": SOURCE, + "input": r#"{"trigger":{"kind":"manual","at":1700000000000}}"#, + })), + ) + .await; + let verdict: serde_json::Value = serde_json::from_str( + ran["verdict"] + .as_str() + .unwrap_or_else(|| panic!("the run failed: {ran}")), + ) + .unwrap(); + + // The plugin got the stub's answer, and was told its connections. + assert_eq!(verdict["status"], 200, "{verdict}"); + assert_eq!(verdict["body"], STUB_BODY, "{verdict}"); + assert_eq!( + verdict["connections"], + serde_json::json!({"demo": "conn-1"}) + ); + + // An undeclared platform is refused before anything leaves the host. + let refused = verdict["refused"].as_str().expect("the other call threw"); + assert!( + refused.contains("does not declare proxy platform 'other'"), + "{refused}" + ); + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen.len(), + 1, + "exactly one request reached the proxy: {seen:?}" + ); + let raw = &seen[0]; + + // integration-proxy's route: /proxy/{connection_id}/{platform}/{path}. + assert!( + raw.starts_with("GET /proxy/conn-1/demo/items HTTP/1.1\r\n"), + "{raw}" + ); + assert_eq!(header(raw, "x-atomic-agent"), vec![node_agent.as_str()]); + assert_ne!(node_agent, alice.subject.to_string()); + assert_ne!(node_agent, app_id); + assert_eq!(header(raw, "x-atomic-signature-version"), vec!["2"]); + + // Verified the way the proxy verifies it: v2, over method, full URL and + // the (empty) body, by the node's agent for this installation. + let url = format!("{proxy_origin}/proxy/conn-1/demo/items"); + let values = |method: &str| atomic_lib::authentication::AuthValues { + public_key: header(raw, "x-atomic-public-key")[0].to_string(), + timestamp: header(raw, "x-atomic-timestamp")[0].parse().unwrap(), + signature: header(raw, "x-atomic-signature")[0].to_string(), + requested_subject: url.clone(), + agent_subject: node_agent.clone(), + request: Some(atomic_lib::authentication::RequestBinding::new(method, b"")), + }; + atomic_lib::authentication::check_auth_signature(&url, &values("GET")) + .expect("a valid v2 signature from the node's app agent"); + assert!( + atomic_lib::authentication::check_auth_signature(&url, &values("POST")).is_err(), + "the signature is bound to the method" + ); + + Ok(()) +} From a0d25c04eae5de2386d10d33ec38725869f569fc Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Mon, 28 Sep 2026 18:14:46 +0200 Subject: [PATCH 2/2] Keep metadata endpoints out of the proxy exception The private-network exception for the configured integration proxy let through two instance-metadata endpoints that sit inside ranges it allows: Alibaba Cloud's 100.100.100.200 (carrier-grade NAT) and AWS's IPv6 IMDS at fd00:ec2::254 (unique-local). Name them and refuse them before the exception applies, including the IPv4-mapped form, both as a literal at startup and as a resolved answer per request. Co-Authored-By: Claude Opus 5.5 --- docs/src/plugins/creating-plugins.md | 2 +- server/src/config.rs | 4 +-- server/src/plugins/egress.rs | 42 ++++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/src/plugins/creating-plugins.md b/docs/src/plugins/creating-plugins.md index 7a13119653..bbd1e2bc07 100644 --- a/docs/src/plugins/creating-plugins.md +++ b/docs/src/plugins/creating-plugins.md @@ -127,7 +127,7 @@ Every field except `schemaVersion` is optional; unknown fields and malformed dec - `secrets`, `operations`, `actions`: as in schema version 1. Secrets name an exact origin a credential may be sent to; operations are exact endpoints with an `effect` of `read` or `write`; actions reference operations. - `network.origins`: exact origins (no wildcards, paths or ports beyond the origin) for packages that call the host `fetch` without an operation id. It never widens what `operations` grant. - `proxy`: integration-proxy platforms the plugin uses, for example `["clockify"]`, also accepted in schema version 1. The plugin calls `ctx.http` with a proxy-relative URL such as `atomic-proxy:/clockify/api/v1/user`, and the operation that admits it is declared with that URL too. The server resolves it to `{--integration-proxy-url}/proxy/{connection_id}/clockify/api/v1/user`, taking the connection id from the Installation's `integrationConnections` (also passed to the plugin as `ctx.connections`), and signs it as the node's agent for the installation. A request is refused when the platform is not declared, when no connection is delegated for it, or when the node has no proxy configured. Calling the proxy by its absolute URL still works but is deprecated. - The proxy the operator configures with `--integration-proxy-url` (`ATOMIC_INTEGRATION_PROXY_URL`) may live on this machine or a private network, such as `http://host.docker.internal:8787` or `http://proxy.lan:8787`: exactly that scheme, host and port may resolve to loopback, private, carrier-grade NAT or IPv6 unique-local addresses, which every other plugin destination is refused. The name is resolved once per request and the host connects to exactly the addresses it checked. Link-local and cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`) are refused even for the proxy, and credentials in the URL are refused. + The proxy the operator configures with `--integration-proxy-url` (`ATOMIC_INTEGRATION_PROXY_URL`) may live on this machine or a private network, such as `http://host.docker.internal:8787` or `http://proxy.lan:8787`: exactly that scheme, host and port may resolve to loopback, private, carrier-grade NAT or IPv6 unique-local addresses, which every other plugin destination is refused. The name is resolved once per request and the host connects to exactly the addresses it checked. Link-local and cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, Alibaba's `100.100.100.200`, AWS's `fd00:ec2::254`) are refused even for the proxy, and credentials in the URL are refused. - `configSchema`, `defaultConfig`: objects, as in `plugin.json`. - `name`, `namespace`, `version`, `description`, `author`: metadata. `name` and `namespace` must be safe path segments. diff --git a/server/src/config.rs b/server/src/config.rs index d16126d2c2..4efdfc2723 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -94,8 +94,8 @@ pub struct Opts { /// on loopback or a private network (e.g. http://host.docker.internal:8787 or /// http://proxy.lan:8787), which every other plugin destination is refused; the name is /// resolved once per request and the checked addresses are the ones connected to. - /// Link-local and cloud-metadata addresses (169.254.0.0/16, fe80::/10) are refused even - /// here. Omit to configure none. + /// Link-local and cloud-metadata addresses (169.254.0.0/16, fe80::/10, 100.100.100.200, + /// fd00:ec2::254) are refused even here. Omit to configure none. #[clap(long, env = "ATOMIC_INTEGRATION_PROXY_URL")] pub integration_proxy_url: Option, diff --git a/server/src/plugins/egress.rs b/server/src/plugins/egress.rs index f28605047d..cac98bea55 100644 --- a/server/src/plugins/egress.rs +++ b/server/src/plugins/egress.rs @@ -33,6 +33,27 @@ pub enum Refusal { UniqueLocal, /// An IPv4 address wearing an IPv6 costume; judged on what it maps to. MappedV4(&'static str), + /// A cloud instance-metadata endpoint that sits inside a range the proxy + /// exception would otherwise allow ([METADATA_V4], [METADATA_V6]). + Metadata, +} + +/// Instance-metadata endpoints outside link-local. Every plugin destination +/// already refuses them as private or unique-local; they are named here so the +/// proxy exception, which lets those ranges through, still refuses them. +/// 100.100.100.200 is Alibaba Cloud's (in carrier-grade NAT). +const METADATA_V4: [Ipv4Addr; 1] = [Ipv4Addr::new(100, 100, 100, 200)]; +/// `fd00:ec2::254` is AWS's IPv6 IMDS endpoint (in unique-local). +const METADATA_V6: [Ipv6Addr; 1] = [Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254)]; + +fn is_metadata(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => METADATA_V4.contains(&v4), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(mapped) => METADATA_V4.contains(&mapped), + None => METADATA_V6.contains(&v6), + }, + } } /// Whether a plugin may connect to this address. @@ -249,8 +270,12 @@ pub async fn checked_addresses_with( /// (`host.docker.internal` is 192.168.65.x on Docker Desktop and 172.17.0.1 on /// Linux; a LAN proxy is 192.168.x.x). Link-local is not: it is where cloud /// instance metadata answers, and that is never a legitimate proxy. Nor are -/// the unspecified and multicast addresses, which are not a host at all. +/// the metadata endpoints that live inside the allowed ranges ([is_metadata]), +/// or the unspecified and multicast addresses, which are not a host at all. pub fn refuse_proxy_address(addr: IpAddr) -> Option { + if is_metadata(addr) { + return Some(Refusal::Metadata); + } match refuse_address(addr)? { Refusal::Loopback | Refusal::Private | Refusal::UniqueLocal => None, Refusal::MappedV4("loopback" | "private") => None, @@ -848,20 +873,30 @@ mod tests { "http://[::ffff:169.254.169.254]:8787", "http://0.0.0.0:8787", "http://224.0.0.1:8787", + "http://100.100.100.200", + "http://[::ffff:100.100.100.200]:8787", + "http://[fd00:ec2::254]", ] { assert!(ProxyOrigin::parse(literal).is_err(), "{literal}"); } + // Their neighbours are still ordinary private proxies. + assert!(ProxyOrigin::parse("http://100.100.100.201:8787").is_ok()); + assert!(ProxyOrigin::parse("http://[fd00:ec2::253]:8787").is_ok()); // A name that resolves there is refused per request, including when // only one of its answers is link-local. let table = Table::new(&[ ("metadata.lan", &["169.254.169.254"]), ("mixed.lan", &["192.168.1.10", "169.254.169.254"]), ("v6.lan", &["fe80::1"]), + ("alibaba.lan", &["100.100.100.200"]), + ("aws6.lan", &["fd00:ec2::254"]), ]); for origin in [ "http://metadata.lan", "http://mixed.lan:8787", "http://v6.lan:8787", + "http://alibaba.lan:8787", + "http://aws6.lan:8787", ] { let proxy = ProxyOrigin::parse(origin).unwrap(); let err = destination_addresses_with( @@ -871,7 +906,10 @@ mod tests { ) .await .unwrap_err(); - assert!(err.contains("LinkLocal"), "{origin}: {err}"); + assert!( + err.contains("LinkLocal") || err.contains("Metadata"), + "{origin}: {err}" + ); } }