diff --git a/CHANGELOG.md b/CHANGELOG.md index 060898d..50a239f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ Releases before 0.7.0 carry their notes in the release commit's message (`RELEASING.md`). +## 0.7.1 + +- **No open bind without a token.** `serve`, `daemon` and `serve-fleet` + refuse at startup, before the wallet is opened or a socket bound, to bind + an address beyond loopback (`BIND_ADDR`, IPv4 or IPv6) when no bearer token + (`AUTH_TOKEN`, an empty value counting as none) is set. The error names the + one flag, `--allow-no-token`, that permits the open bind deliberately. A + loopback bind without a token is unchanged. +- **The risk it closes.** Since 0.7.0 the served `/internalizeAction` and + `/createAction` doors take a body of any size, auth before the body; a + daemon bound to `0.0.0.0` with no token was a wallet any host on the + network could drive, and made to hold any body it sent. + +### Upgrade + +A daemon started with `BIND_ADDR` beyond loopback and no `AUTH_TOKEN` now +exits non-zero at startup: set `AUTH_TOKEN` (and send it as +`Authorization: Bearer`), or add `--allow-no-token` to keep the open bind. +The library gains `server::refuse_open_bind`, +`server::refuse_open_bind_from_env` and `server::bind_addr_from_env`; +`server::run` itself does not check, so a host calling it directly checks +first. + ## 0.7.0 The 0.4 line: bsv-rs 0.4.1 under the CLI, the toolbox and the tracker, one diff --git a/Cargo.lock b/Cargo.lock index 623e902..f89d8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,7 +357,7 @@ dependencies = [ [[package]] name = "bsv-wallet-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index f232ee8..2723652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "bsv-wallet-cli" -version = "0.7.0" +version = "0.7.1" edition = "2021" license = "MIT" authors = ["John Calhoun"] diff --git a/README.md b/README.md index 537d656..6979e38 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ warns and you should pick an earlier date for real value. All 28 BRC WalletInterface endpoints on `http://127.0.0.1:3322`, wire-compatible with MetaNet Client. +`serve`, `daemon` and `serve-fleet` refuse at startup to bind an address beyond loopback (`BIND_ADDR`) when no `AUTH_TOKEN` is set, since a wallet served without a token answers anyone who can reach it and its BEEF doors take a body of any size. Set `AUTH_TOKEN`, or pass `--allow-no-token` to bind open deliberately; a loopback bind without a token is unchanged. + ### Endpoints **Status** (GET) @@ -168,7 +170,7 @@ All configuration is via environment variables: | `ROOT_KEY` | Yes | Wallet root private key (hex). Set by `bsv-wallet init`. | | `CHAINTRACKS_URL` | Yes | Header service that validates merkle proofs: your own `chaintracks-cloudflare` or rust-chaintracks deployment. There is no default; a command that builds the wallet's services refuses to run without it (`CHAINTRACKS_URL is not set`). `off` runs with no header service: every merkle proof is refused and nothing is marked proven, and `tick` and `reproof --execute` exit non-zero | | `BREAK_GLASS_EXPLORER_HEADERS` | No | Break-glass, off by default. `1`, `true` or `yes` lets the proof path ask WhatsOnChain (then Bitails) for block headers when the header service gives no answer; every such call is logged at warn level. An explorer never overrules the header service's own answer | -| `AUTH_TOKEN` | No | Bearer token for HTTP auth (localhost-only, optional) | +| `AUTH_TOKEN` | No | Bearer token for HTTP auth. Optional on loopback; a bind beyond loopback without it is refused unless `--allow-no-token` is passed | | `TLS_CERT_PATH` | No | TLS certificate path (requires `--features tls`) | | `TLS_KEY_PATH` | No | TLS private key path (requires `--features tls`) | | `MIN_UTXOS` | No | Low UTXO warning threshold in daemon mode (default: 3) | @@ -176,7 +178,7 @@ All configuration is via environment variables: | `ARC_MODE=arcade` / `ARCADE=1` | No | Arcade V2 mode: EF-only submit, SSE push statuses, push proofs | | `CALLBACK_TOKEN` | No | Override the per-wallet callback token (auto-generated + persisted as `.callback-token` otherwise) | | `PUBLIC_CALLBACK_URL` | No | Public HTTPS URL for Arcade status webhooks (`X-CallbackUrl`), pointing at this daemon's `/arc-callback` | -| `BIND_ADDR` | No | HTTP server bind address (default `127.0.0.1`; set `0.0.0.0` for the tunnel/webhook case) | +| `BIND_ADDR` | No | HTTP server bind address (default `127.0.0.1`; set `0.0.0.0` for the tunnel/webhook case, with `AUTH_TOKEN`) | | `RELAY_URL` / `RELAY_POLL_SECS` | No | Drain a `bsv-wallet-relay` queue (see proof-delivery ladder) | | `TAAL_API_KEY` / `MAIN_TAAL_API_KEY` | No | TAAL ARC auth (Bearer / raw `Authorization` respectively) | | `TRACKER_AGE_SECS` | No | Seconds without a new word from a broadcaster before the tracker asks for a transaction's merkle proof (default 600); the pause between two asks for the same transaction doubles from there | diff --git a/src/cli.rs b/src/cli.rs index d5ca9ab..a393783 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -158,9 +158,19 @@ pub enum Commands { /// 60 s. Refused (non-zero exit) when CHAINTRACKS_URL=off TrackerTick, /// Run monitor + HTTP server (foreground) - Daemon, + Daemon { + /// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without + /// this flag such a bind is refused at startup + #[arg(long)] + allow_no_token: bool, + }, /// Run HTTP server only (no monitor) - Serve, + Serve { + /// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without + /// this flag such a bind is refused at startup + #[arg(long)] + allow_no_token: bool, + }, /// ONE process serving MANY wallets (fleet mode): each --wallet /// : serves /wallet.db on its own port with the /// ROOT_KEY read from /.env (never process env). Per-seat port @@ -174,6 +184,10 @@ pub enum Commands { /// never proves 0-conf ancestry; the createAction-502 class returns). #[arg(long)] daemon: bool, + /// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without + /// this flag such a bind is refused at startup + #[arg(long)] + allow_no_token: bool, }, /// Split UTXOs into multiple outputs for concurrency Split { diff --git a/src/commands/daemon_tenant.rs b/src/commands/daemon_tenant.rs index 02e0a8d..ae5850a 100644 --- a/src/commands/daemon_tenant.rs +++ b/src/commands/daemon_tenant.rs @@ -128,10 +128,7 @@ pub async fn start(db: &str, root_key_hex: &str, chain: Chain, port: u16) -> Res .ok() .filter(|s| !s.is_empty()) }); - let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1])); + let bind_addr = server::bind_addr_from_env(); let config = ServerConfig { auth_token: std::env::var("AUTH_TOKEN").ok(), tls, diff --git a/src/commands/serve.rs b/src/commands/serve.rs index f0788bf..9c380a7 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -26,10 +26,7 @@ pub async fn run(ctx: WalletContext, port: u16) -> Result<()> { .filter(|s| !s.is_empty()) }); - let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1])); + let bind_addr = server::bind_addr_from_env(); let config = ServerConfig { auth_token: std::env::var("AUTH_TOKEN").ok(), diff --git a/src/commands/serve_fleet.rs b/src/commands/serve_fleet.rs index 71ecb54..4440a35 100644 --- a/src/commands/serve_fleet.rs +++ b/src/commands/serve_fleet.rs @@ -172,10 +172,7 @@ fn tenant_config(ctx: &WalletContext) -> Result { .ok() .filter(|s| !s.is_empty()) }); - let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1])); + let bind_addr = server::bind_addr_from_env(); Ok(ServerConfig { auth_token: std::env::var("AUTH_TOKEN").ok(), tls, diff --git a/src/main.rs b/src/main.rs index da14ed9..b881202 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,14 +107,21 @@ async fn main() -> Result<()> { let ctx = context::WalletContext::load(&cli).await?; commands::tracker_tick::run(&ctx).await?; } - Commands::Daemon => { + Commands::Daemon { allow_no_token } => { + server::refuse_open_bind_from_env(*allow_no_token)?; commands::daemon::run(&cli).await?; } - Commands::Serve => { + Commands::Serve { allow_no_token } => { + server::refuse_open_bind_from_env(*allow_no_token)?; let ctx = context::WalletContext::load(&cli).await?; commands::serve::run(ctx, cli.port).await?; } - Commands::ServeFleet { wallet, daemon } => { + Commands::ServeFleet { + wallet, + daemon, + allow_no_token, + } => { + server::refuse_open_bind_from_env(*allow_no_token)?; commands::serve_fleet::run(wallet, cli.testnet, *daemon).await?; } Commands::Split { count } => { diff --git a/src/server/mod.rs b/src/server/mod.rs index 44f50b9..c59f402 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -68,6 +68,43 @@ impl Default for ServerConfig { } } +/// The address to bind: `BIND_ADDR` when it parses, else `127.0.0.1`. Read +/// by every served command and by the bind guard, so the two agree. +pub fn bind_addr_from_env() -> std::net::IpAddr { + std::env::var("BIND_ADDR") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1])) +} + +/// The bind guard (0.7.1). With no bearer token a served wallet answers any +/// caller that can reach it, and its two BEEF doors take a body of any size +/// (0.7.0), so an address beyond loopback needs a token or the operator's +/// explicit `allow_no_token`. An empty token counts as none. Loopback is +/// unchanged. Run at startup, before the wallet is opened or a socket bound. +pub fn refuse_open_bind( + bind_addr: std::net::IpAddr, + auth_token: Option<&str>, + allow_no_token: bool, +) -> Result<()> { + let has_token = auth_token.is_some_and(|t| !t.is_empty()); + if has_token || allow_no_token || bind_addr.to_canonical().is_loopback() { + return Ok(()); + } + anyhow::bail!( + "refusing to bind {bind_addr}: no bearer token is configured, and a wallet served \ + beyond loopback without one answers anyone who can reach it. Set AUTH_TOKEN, or pass \ + --allow-no-token to bind it open deliberately." + ) +} + +/// [`refuse_open_bind`] on the environment the served commands read +/// (`BIND_ADDR`, `AUTH_TOKEN`). +pub fn refuse_open_bind_from_env(allow_no_token: bool) -> Result<()> { + let token = std::env::var("AUTH_TOKEN").ok(); + refuse_open_bind(bind_addr_from_env(), token.as_deref(), allow_no_token) +} + /// Auth middleware — checks Bearer token if configured. async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response { // /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by @@ -513,3 +550,40 @@ mod lenient_json_tests { assert_eq!(v["keyID"], "ab \u{fffd} cd"); } } + +#[cfg(test)] +mod bind_guard_tests { + use super::refuse_open_bind; + use std::net::IpAddr; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn beyond_loopback_needs_a_token_or_the_flag() { + for open in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1"] { + let err = refuse_open_bind(ip(open), None, false).unwrap_err(); + assert!( + err.to_string().contains("--allow-no-token"), + "{open}: {err}" + ); + assert!( + refuse_open_bind(ip(open), Some(""), false).is_err(), + "{open}" + ); + assert!( + refuse_open_bind(ip(open), Some("t"), false).is_ok(), + "{open}" + ); + assert!(refuse_open_bind(ip(open), None, true).is_ok(), "{open}"); + } + } + + #[test] + fn loopback_is_unchanged() { + for lo in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] { + assert!(refuse_open_bind(ip(lo), None, false).is_ok(), "{lo}"); + } + } +} diff --git a/tests/serve_bind_guard.rs b/tests/serve_bind_guard.rs new file mode 100644 index 0000000..b1f475c --- /dev/null +++ b/tests/serve_bind_guard.rs @@ -0,0 +1,198 @@ +//! 0.7.1 (bsv-stack-lean, the CLI 0.7.1 lane): a served wallet with no bearer +//! token answers any caller that can reach it, and since 0.7.0 its two BEEF +//! doors take a body of any size. So `serve`, `daemon` and `serve-fleet` +//! refuse at startup to bind an address beyond loopback when no token is +//! configured, before the wallet is opened or any socket is bound; +//! `--allow-no-token` permits the open bind deliberately; loopback without a +//! token is unchanged. The binary is run as a user runs it: a throwaway wallet +//! made by `init` in a temp dir, `CHAINTRACKS_URL=off` and the served loop off, +//! so nothing reaches the network. + +use std::io::{BufRead, BufReader}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; +use tempfile::TempDir; + +fn wallet(dir: &TempDir) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_bsv-wallet")); + cmd.current_dir(dir.path()) + .env_remove("ROOT_KEY") + .env_remove("AUTH_TOKEN") + .env_remove("BIND_ADDR") + .env_remove("TLS_CERT_PATH") + .env_remove("TLS_KEY_PATH") + .env_remove("ARC_MODE") + .env_remove("ARCADE") + .env_remove("ARC_URL") + .env("CHAINTRACKS_URL", "off") + .env("BROADCAST_RECONCILE", "0") + .env("RUST_LOG", "warn"); + cmd +} + +fn init(dir: &TempDir) { + let out = wallet(dir) + .args(["--db", "wallet.db", "init"]) + .output() + .expect("run init"); + assert!( + out.status.success(), + "init: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("probe bind") + .local_addr() + .expect("probe addr") + .port() +} + +/// The child is killed by its own pid when the guard drops, pass or fail. +struct Running(Child); + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Spawn the command and wait (bounded) for it to exit or to print its +/// listening line. Returns the exit status if it exited, and the stderr read. +fn run_until_listening_or_exit(mut cmd: Command) -> (Option, String) { + let mut child = cmd + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn"); + let stderr = child.stderr.take().expect("stderr"); + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + let mut running = Running(child); + let mut seen = String::new(); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while std::time::Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(line) => { + let listening = line.contains("server listening on"); + seen.push_str(&line); + seen.push('\n'); + if listening { + return (None, seen); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + let status = running.0.wait().expect("wait"); + return (Some(status), seen); + } + } + if let Some(status) = running.0.try_wait().expect("try_wait") { + while let Ok(line) = rx.recv_timeout(Duration::from_millis(200)) { + seen.push_str(&line); + seen.push('\n'); + } + return (Some(status), seen); + } + } + panic!("neither exited nor listened within 30 s: {seen}"); +} + +#[test] +fn an_open_bind_without_a_token_is_refused_before_any_socket_is_opened() { + for command in [ + vec!["serve"], + vec!["daemon"], + vec!["serve-fleet", "--wallet", "seat:3999"], + ] { + let dir = TempDir::new().expect("temp dir"); + init(&dir); + let port = free_port(); + let mut cmd = wallet(&dir); + cmd.env("BIND_ADDR", "0.0.0.0") + .args(["--db", "wallet.db", "--port", &port.to_string()]) + .args(&command); + let (status, stderr) = run_until_listening_or_exit(cmd); + let status = status.unwrap_or_else(|| panic!("{command:?} bound open: {stderr}")); + assert!(!status.success(), "{command:?} must fail: {stderr}"); + assert!( + stderr.contains("no bearer token") && stderr.contains("--allow-no-token"), + "{command:?}: the one plain error names the flag: {stderr}" + ); + assert!( + !stderr.contains("listening on"), + "{command:?}: no socket was opened: {stderr}" + ); + assert!( + TcpStream::connect(("127.0.0.1", port)).is_err(), + "{command:?}: nothing listens on {port}" + ); + } +} + +#[test] +fn the_flag_permits_the_open_bind() { + let dir = TempDir::new().expect("temp dir"); + init(&dir); + let port = free_port(); + let mut cmd = wallet(&dir); + cmd.env("BIND_ADDR", "0.0.0.0").args([ + "--db", + "wallet.db", + "--port", + &port.to_string(), + "serve", + "--allow-no-token", + ]); + let (status, stderr) = run_until_listening_or_exit(cmd); + assert!(status.is_none(), "serve --allow-no-token exited: {stderr}"); + assert!( + stderr.contains(&format!("listening on 0.0.0.0:{port}")), + "{stderr}" + ); +} + +#[test] +fn a_token_permits_the_open_bind() { + let dir = TempDir::new().expect("temp dir"); + init(&dir); + let port = free_port(); + let mut cmd = wallet(&dir); + cmd.env("BIND_ADDR", "0.0.0.0") + .env("AUTH_TOKEN", "witness-token") + .args(["--db", "wallet.db", "--port", &port.to_string(), "serve"]); + let (status, stderr) = run_until_listening_or_exit(cmd); + assert!(status.is_none(), "serve with a token exited: {stderr}"); + assert!( + stderr.contains(&format!("listening on 0.0.0.0:{port}")), + "{stderr}" + ); +} + +#[test] +fn loopback_without_a_token_binds_as_before() { + for bind in [None, Some("127.0.0.1"), Some("::1")] { + let dir = TempDir::new().expect("temp dir"); + init(&dir); + let port = free_port(); + let mut cmd = wallet(&dir); + if let Some(addr) = bind { + cmd.env("BIND_ADDR", addr); + } + cmd.args(["--db", "wallet.db", "--port", &port.to_string(), "serve"]); + let (status, stderr) = run_until_listening_or_exit(cmd); + assert!(status.is_none(), "{bind:?}: serve exited: {stderr}"); + assert!(stderr.contains("listening on"), "{bind:?}: {stderr}"); + } +}