From 450364d298a9cc2506f4a93dea5c0f4b8e2c5391 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:08:31 -0700 Subject: [PATCH 1/2] cli: take host:port for --rpc-url The flag used to strip http:// and https:// and then open a plain TCP socket. Pass host:port only. A scheme, path, or userinfo is an error, so a bearer token is not sent when the operator wrote https. --- changelog.d/cli-rpc-host-port.md | 5 ++ crates/rbitcoin-cli/src/lib.rs | 85 +++++++++++++++++++++++++------- docs/lightning.md | 5 +- docs/rpc.md | 9 ++-- 4 files changed, 79 insertions(+), 25 deletions(-) create mode 100644 changelog.d/cli-rpc-host-port.md diff --git a/changelog.d/cli-rpc-host-port.md b/changelog.d/cli-rpc-host-port.md new file mode 100644 index 000000000..29eb6bcf3 --- /dev/null +++ b/changelog.d/cli-rpc-host-port.md @@ -0,0 +1,5 @@ +Changed + +- `rbitcoin-cli --rpc-url` takes `host:port` (plain HTTP). A scheme, path, or + userinfo is rejected, including `https://`, which used to be stripped and + then dialed without TLS. diff --git a/crates/rbitcoin-cli/src/lib.rs b/crates/rbitcoin-cli/src/lib.rs index a4feaa982..f63920cb5 100644 --- a/crates/rbitcoin-cli/src/lib.rs +++ b/crates/rbitcoin-cli/src/lib.rs @@ -17,7 +17,7 @@ fn usage() -> String { --datadir PATH node datadir (default ./datadir); unix socket PATH/rpc.sock\n\ --rpc-socket PATH unix socket the node binds with --rpc-socket\n\ --network NET mainnet|testnet|signet|regtest (default TCP port)\n\ - --rpc-url URL HTTP JSON-RPC (default http://127.0.0.1:)\n\ + --rpc-url ADDR plain HTTP host:port (else 127.0.0.1: if no socket)\n\ --rpc-token-file PATH Bearer token (default PATH/rpc.token from --datadir)\n\ -h, --help this message\n\ -V, --version print version\n\ @@ -133,23 +133,40 @@ fn token_path(cfg: &CliConfig) -> PathBuf { .unwrap_or_else(|| cfg.datadir.join("rpc.token")) } -fn parse_http_url(url: &str) -> Result<(String, u16), String> { - let rest = url - .strip_prefix("http://") - .or_else(|| url.strip_prefix("https://")) - .unwrap_or(url); - let (host, port) = rest - .rsplit_once(':') - .ok_or_else(|| format!("--rpc-url needs host:port (got {url})"))?; - let port: u16 = port - .parse() - .map_err(|_| format!("invalid --rpc-url port in {url}"))?; +fn parse_rpc_addr(addr: &str) -> Result<(String, u16), String> { + let bad = || format!("--rpc-url takes host:port (plain HTTP, no scheme; got {addr})"); + if addr.contains("://") || addr.contains('/') || addr.contains('@') { + return Err(bad()); + } + let (raw_host, port_s) = addr.rsplit_once(':').ok_or_else(bad)?; + let host = if let Some(inner) = raw_host.strip_prefix('[') { + let inner = inner.strip_suffix(']').ok_or_else(bad)?; + if !inner.contains(':') { + return Err(bad()); + } + inner + } else if raw_host.contains(':') { + return Err(bad()); + } else { + raw_host + }; if host.is_empty() { - return Err(format!("invalid --rpc-url host in {url}")); + return Err(format!("invalid --rpc-url host in {addr}")); } + let port: u16 = port_s + .parse() + .map_err(|_| format!("invalid --rpc-url port in {addr}"))?; Ok((host.to_string(), port)) } +fn http_host_value(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + fn read_token(path: &Path) -> Result { let line = std::fs::read_to_string(path).map_err(|e| format!("read token {}: {e}", path.display()))?; @@ -183,9 +200,10 @@ fn rpc_http( Some(t) => format!("Authorization: Bearer {t}\r\n"), None => String::new(), }; + let host_hdr = http_host_value(host, port); let req = format!( "POST / HTTP/1.1\r\n\ - Host: {host}:{port}\r\n\ + Host: {host_hdr}\r\n\ {auth_h}\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ @@ -262,7 +280,7 @@ fn dispatch_call(cfg: &CliConfig) -> Result { rpc_http(stream, "localhost", 0, None, cmd, ¶ms)? } else { let (host, port) = match cfg.rpc_url.as_deref() { - Some(u) => parse_http_url(u)?, + Some(u) => parse_rpc_addr(u)?, None => ("127.0.0.1".into(), cfg.network.default_rpc_port()), }; let stream = TcpStream::connect((host.as_str(), port)) @@ -375,6 +393,35 @@ mod tests { (port, h) } + #[test] + fn rpc_url_is_host_and_port() { + assert_eq!( + parse_rpc_addr("127.0.0.1:8332").unwrap(), + ("127.0.0.1".into(), 8332) + ); + assert_eq!( + parse_rpc_addr("rpc.lan:18443").unwrap(), + ("rpc.lan".into(), 18443) + ); + assert_eq!(parse_rpc_addr("[::1]:8332").unwrap(), ("::1".into(), 8332)); + assert_eq!(http_host_value("127.0.0.1", 8332), "127.0.0.1:8332"); + assert_eq!(http_host_value("::1", 8332), "[::1]:8332"); + for bad in [ + "http://127.0.0.1:8332", + "https://127.0.0.1:8332", + "127.0.0.1:8332/", + "user:pass@127.0.0.1:8332", + "::1:8332", + "127.0.0.1", + ] { + let err = parse_rpc_addr(bad).unwrap_err(); + assert!(err.contains("host:port"), "{bad} -> {err}"); + } + let u = usage(); + assert!(u.contains("--rpc-url ADDR"), "{u}"); + assert!(!u.contains("http://"), "{u}"); + } + #[test] fn param_tokens_json_or_string() { assert_eq!(param_value("0"), serde_json::json!(0)); @@ -391,7 +438,7 @@ mod tests { let code = cli_main([ "rbitcoin-cli", flag.as_str(), - &format!("--rpc-url=http://127.0.0.1:{port}"), + &format!("--rpc-url=127.0.0.1:{port}"), "getblockcount", ]); let _ = std::fs::remove_dir_all(&dir); @@ -405,7 +452,7 @@ mod tests { for needle in [ "--datadir PATH", "--network NET", - "--rpc-url URL", + "--rpc-url ADDR", "--rpc-token-file PATH", "-h, --help", "-V, --version", @@ -432,7 +479,7 @@ mod tests { "--datadir", dir.to_str().unwrap(), "--rpc-url", - &format!("http://127.0.0.1:{port}"), + &format!("127.0.0.1:{port}"), "getblockcount", ]); let _ = std::fs::remove_dir_all(&dir); @@ -452,7 +499,7 @@ mod tests { "--datadir", dir.to_str().unwrap(), "--rpc-url", - &format!("http://127.0.0.1:{port}"), + &format!("127.0.0.1:{port}"), "getblockcount", ]); let _ = std::fs::remove_dir_all(&dir); diff --git a/docs/lightning.md b/docs/lightning.md index e810013d3..61612fcbf 100644 --- a/docs/lightning.md +++ b/docs/lightning.md @@ -69,8 +69,9 @@ Wrapper [`scripts/lightning/bitcoin-cli`](../scripts/lightning/bitcoin-cli) talk | `getutxout` | `gettxout` | Live coin: `value` BTC, `scriptPubKey.hex`. Spent / unknown / disconnected archive: JSON **`null`** (not RPC error). | | `sendrawtransaction` | `sendrawtransaction` | `maxfeerate` is **sat/vB** (default 10000). CLN `allowhighfees` must pass **`0`**. | -`rbitcoin-cli` argv is `--datadir` / `--rpc-url`, not Core `-datadir=` / -`-rpcport`. That is why the wrapper exists. +`rbitcoin-cli` argv is `--datadir` / `--rpc-url host:port`, not Core +`-datadir=` / `-rpcport`. The value is an address, not a URL. That is why +the wrapper exists. ## LDK / ldk-node diff --git a/docs/rpc.md b/docs/rpc.md index b9eba324f..386411abe 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -99,13 +99,14 @@ curl -H "Authorization: Bearer $TOKEN" --data-binary \ `--datadir` (default `./datadir`) prefers `{datadir}/rpc.sock`. `--rpc-socket PATH` talks to a node started with `--rpc-socket PATH`. TCP uses -`--rpc-url` (default `http://127.0.0.1:`) and Bearer from -`{datadir}/rpc.token` or `--rpc-token-file`. Prints the JSON-RPC `result` -(strings unquoted). +`--rpc-url host:port` (plain HTTP; no scheme). When the flag is omitted and no +socket is present, the client uses `127.0.0.1` and the network RPC port. Bearer +comes from `{datadir}/rpc.token` or `--rpc-token-file`. Prints the JSON-RPC +`result` (strings unquoted). ```bash rbitcoin-cli --datadir datadir getblockcount -rbitcoin-cli --network regtest --rpc-url http://127.0.0.1:18443 getblockchaininfo +rbitcoin-cli --network regtest --rpc-url 127.0.0.1:18443 getblockchaininfo ``` ## shindex matrix From 03cba72dfd321cf8d80f9069ea4325e668c403b2 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:21:36 -0700 Subject: [PATCH 2/2] cli: reject inet_aton spellings of --rpc-url A strict IP connects as a SocketAddr. Leading zeros, short quads, and hex forms are an error, so getaddrinfo cannot send the bearer token to a rewritten address. --- changelog.d/cli-rpc-host-port.md | 4 +- crates/rbitcoin-cli/src/lib.rs | 72 +++++++++++++++++++++++++++++--- docs/rpc.md | 3 +- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/changelog.d/cli-rpc-host-port.md b/changelog.d/cli-rpc-host-port.md index 29eb6bcf3..f8b73b804 100644 --- a/changelog.d/cli-rpc-host-port.md +++ b/changelog.d/cli-rpc-host-port.md @@ -2,4 +2,6 @@ Changed - `rbitcoin-cli --rpc-url` takes `host:port` (plain HTTP). A scheme, path, or userinfo is rejected, including `https://`, which used to be stripped and - then dialed without TLS. + then dialed without TLS. A numeric host must be a strict IP. Leading zeros, + short quads, and hex forms are rejected so the bearer token is not sent to + the address `getaddrinfo` would rewrite them into. diff --git a/crates/rbitcoin-cli/src/lib.rs b/crates/rbitcoin-cli/src/lib.rs index f63920cb5..c961b294a 100644 --- a/crates/rbitcoin-cli/src/lib.rs +++ b/crates/rbitcoin-cli/src/lib.rs @@ -4,7 +4,7 @@ use rbitcoin_primitives::Network; use serde_json::Value; use std::ffi::OsString; use std::io::{Read, Write}; -use std::net::TcpStream; +use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::Duration; @@ -150,8 +150,8 @@ fn parse_rpc_addr(addr: &str) -> Result<(String, u16), String> { } else { raw_host }; - if host.is_empty() { - return Err(format!("invalid --rpc-url host in {addr}")); + if host.is_empty() || !acceptable_host(host) { + return Err(bad()); } let port: u16 = port_s .parse() @@ -159,6 +159,51 @@ fn parse_rpc_addr(addr: &str) -> Result<(String, u16), String> { Ok((host.to_string(), port)) } +/// A strict IP, a scoped IPv6 address, or a DNS name. inet_aton spellings +/// (`010.0.0.1`, `0x7f.0.0.1`) are rejected so libc does not retarget them. +fn acceptable_host(host: &str) -> bool { + if let Some((addr, zone)) = host.split_once('%') { + return addr.parse::().is_ok() && valid_zone(zone); + } + host.parse::().is_ok() || !inet_aton_literal(host) +} + +fn valid_zone(zone: &str) -> bool { + !zone.is_empty() + && !zone.contains('%') + && zone + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') +} + +fn inet_aton_literal(host: &str) -> bool { + host.split('.').all(inet_aton_label) +} + +fn inet_aton_label(label: &str) -> bool { + if label.is_empty() { + return false; + } + if label.bytes().all(|b| b.is_ascii_digit()) { + return true; + } + let Some(hex) = label + .strip_prefix("0x") + .or_else(|| label.strip_prefix("0X")) + else { + return false; + }; + !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn connect_tcp(host: &str, port: u16) -> Result { + let connect = match host.parse::() { + Ok(ip) => TcpStream::connect(SocketAddr::new(ip, port)), + Err(_) => TcpStream::connect((host, port)), + }; + connect.map_err(|e| format!("connect {host}:{port}: {e}")) +} + fn http_host_value(host: &str, port: u16) -> String { if host.contains(':') { format!("[{host}]:{port}") @@ -283,8 +328,7 @@ fn dispatch_call(cfg: &CliConfig) -> Result { Some(u) => parse_rpc_addr(u)?, None => ("127.0.0.1".into(), cfg.network.default_rpc_port()), }; - let stream = TcpStream::connect((host.as_str(), port)) - .map_err(|e| format!("connect {host}:{port}: {e}"))?; + let stream = connect_tcp(&host, port)?; stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); stream.set_write_timeout(Some(Duration::from_secs(30))).ok(); let token = read_token(&token_path(cfg))?; @@ -404,6 +448,18 @@ mod tests { ("rpc.lan".into(), 18443) ); assert_eq!(parse_rpc_addr("[::1]:8332").unwrap(), ("::1".into(), 8332)); + assert_eq!( + parse_rpc_addr("[fe80::1%lo]:8332").unwrap(), + ("fe80::1%lo".into(), 8332) + ); + assert_eq!( + parse_rpc_addr("dead.beef:8332").unwrap(), + ("dead.beef".into(), 8332) + ); + assert_eq!( + parse_rpc_addr("0x10.lan:8332").unwrap(), + ("0x10.lan".into(), 8332) + ); assert_eq!(http_host_value("127.0.0.1", 8332), "127.0.0.1:8332"); assert_eq!(http_host_value("::1", 8332), "[::1]:8332"); for bad in [ @@ -413,6 +469,12 @@ mod tests { "user:pass@127.0.0.1:8332", "::1:8332", "127.0.0.1", + "010.0.0.1:8332", + "0x7f.0.0.1:8332", + "0x7f000001:8332", + "127.1:8332", + "2130706433:8332", + "[fe80::1%]:8332", ] { let err = parse_rpc_addr(bad).unwrap_err(); assert!(err.contains("host:port"), "{bad} -> {err}"); diff --git a/docs/rpc.md b/docs/rpc.md index 386411abe..7437da709 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -99,7 +99,8 @@ curl -H "Authorization: Bearer $TOKEN" --data-binary \ `--datadir` (default `./datadir`) prefers `{datadir}/rpc.sock`. `--rpc-socket PATH` talks to a node started with `--rpc-socket PATH`. TCP uses -`--rpc-url host:port` (plain HTTP; no scheme). When the flag is omitted and no +`--rpc-url host:port` (plain HTTP; no scheme). An IP is a strict address +(`10.0.0.1`, `[::1]`); leading zeros and hex forms are rejected. When the flag is omitted and no socket is present, the client uses `127.0.0.1` and the network RPC port. Bearer comes from `{datadir}/rpc.token` or `--rpc-token-file`. Prints the JSON-RPC `result` (strings unquoted).