From 91f0e248a4c52a5f53154f2689e20b1db054aa1f Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:58:29 -0700 Subject: [PATCH 01/11] rpc: serve REST only when --rest is set Unauthenticated /rest/ was on every RPC listener and took an RPC work-queue permit before the body was read. The flag defaults off. The body is read first, and REST uses its own queue. --- changelog.d/rest-electrum.md | 4 ++ crates/rbitcoin-node/src/cli.rs | 6 +- crates/rbitcoin-node/src/config.rs | 10 +++ crates/rbitcoin-node/src/run.rs | 1 + crates/rbitcoin-rpc/src/server.rs | 72 +++++++++++++++++-- .../052-livera-review-index.md | 2 +- docs/lightning.md | 6 +- docs/operator/interfaces.md | 2 +- docs/operator/operations.md | 1 + docs/rpc.md | 3 +- nix/modules/rbitcoin.nix | 11 +++ nix/tests/nixos-module-eval.nix | 18 +++++ 12 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 changelog.d/rest-electrum.md diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md new file mode 100644 index 000000000..fe8d5ce5c --- /dev/null +++ b/changelog.d/rest-electrum.md @@ -0,0 +1,4 @@ +Security + +- `/rest/` on the RPC listener is off unless `--rest` or `rest=` is set. + It uses its own queue, and the body is read before that permit is taken. diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 9f54760fb..04bc55247 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -304,7 +304,7 @@ fn operator_usage() -> String { [--i2p-sam [HOST:PORT]] [--i2p-accept-incoming] \\\n\ [--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] [--health-listen [ADDR]] [--metrics] \\\n\ [--sh-index] [--block-filter-index] [--prune-seqsigwit] [--prune-seqsigwit-ram-threshold-bytes N] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--electrum-max-subs N] [--esplora-block-template] \\\n\ - [--rpc] [--rpc-listen [ADDR]] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ + [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ [--milestone HEIGHT] \\\n\ [--max-outbound N] [--max-inbound N] \\\n\ [--mempool-size-mb N] [--mempool-expiry HOURS] \\\n\ @@ -357,7 +357,7 @@ Silent payments: --sp-tweaks (default off) writes/serves the thin BIP-352 tweak Health: --health-listen [ADDR] serves GET /healthz from the first second of startup\n\ and GET /readyz (default 127.0.0.1:9332). Unauthenticated; keep it on loopback or a\n\ probe-only network. --metrics adds Prometheus GET /metrics there (needs --health-listen).\n\ -RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser.\n\ +RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ Cold files: --datadir-cold PATH puts Class A seqsigwit.body/idx under PATH/store (HDD).\n\ Default (flag omitted): hot and cold files both live under --datadir.\n\ Conf: --conf FILE (snake_case key=value; CLI kebab overrides conf). See OPERATOR.md and docs/rpc.md.\n\ @@ -413,6 +413,7 @@ fn is_bool_key(key: &str) -> bool { | "i2p_accept_incoming" | "inhibit_suspend" | "metrics" + | "rest" | "trusted" | "always_relay" | "relay" @@ -625,6 +626,7 @@ mod tests { "--esplora-onion", "--rpc", "--rpc-listen", + "--rest", "--rpc-socket", "--rpc-token-file", "--rpc-cookie-file", diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index a6c8d91a3..75ca0c54e 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -252,6 +252,8 @@ pub struct RpcOpts { /// Opt-in Core-format `username:password` cookie accepted as TCP HTTP Basic. pub cookie_file: Option, pub work_queue: Option, + /// Core REST on the RPC listener. Off unless `--rest` / `rest=`. + pub rest: bool, } impl Default for RpcOpts { @@ -264,6 +266,7 @@ impl Default for RpcOpts { token_file: None, cookie_file: None, work_queue: Some(rbitcoin_rpc::DEFAULT_RPC_WORK_QUEUE), + rest: false, } } } @@ -617,6 +620,9 @@ impl NodeConfig { if self.metrics && self.listen.health.is_none() { return Err(NodeError::Config("--metrics needs --health-listen".into())); } + if self.rpc.rest && self.rpc.listen.is_none() && !self.rpc.socket { + return Err(NodeError::Config("--rest needs --rpc or --rpc-listen".into())); + } self.validate_only_net()?; self.validate_hidden_inbound()?; self.validate_rpc_cookie() @@ -1325,6 +1331,10 @@ impl NodeConfig { self.metrics = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf metrics: {e}")))?; } + "rest" => { + self.rpc.rest = parse_conf_bool(val) + .map_err(|e| NodeError::Config(format!("conf rest: {e}")))?; + } "inhibit_suspend" => { self.inhibit_suspend = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf inhibit_suspend: {e}")))?; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 7348742fc..073014b95 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -876,6 +876,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { token_path: Some(config.rpc_token_path()), cookie_path: config.rpc_cookie_path(), work_queue: config.rpc.work_queue, + rest: config.rpc.rest, subversion: Some( rbitcoin_primitives::rbitcoin_subversion( env!("CARGO_PKG_VERSION"), diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index 416c0f1d9..cee5c6d64 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -55,6 +55,8 @@ pub struct RpcConfig { pub subversion: Option, /// HTTP occupancy cap. `None` and `0` are [`DEFAULT_RPC_WORK_QUEUE`]. pub work_queue: Option, + /// `GET`/`POST /rest/` on this listener. Off unless `--rest` / `rest=`. + pub rest: bool, /// `--alert-notify` (`%s` = warning text). pub alert_notify: Option, } @@ -91,6 +93,9 @@ struct AppState { auth: RpcAuth, cookie: Option, work_queue: Arc, + /// Separate from [`Self::work_queue`]. REST must not take an RPC slot. + rest_queue: Arc, + rest: bool, require_auth: bool, } @@ -156,6 +161,7 @@ pub async fn run_rpc( let n = work_queue_permits(config.work_queue); let work_queue = Arc::new(tokio::sync::Semaphore::new(n)); + let rest_queue = Arc::new(tokio::sync::Semaphore::new(DEFAULT_RPC_WORK_QUEUE)); let shutdown = Arc::new(AtomicBool::new(false)); let mut tasks = Vec::new(); let mut local_addr = None; @@ -173,6 +179,8 @@ pub async fn run_rpc( auth: auth.clone(), cookie: cookie.clone(), work_queue: work_queue.clone(), + rest_queue: rest_queue.clone(), + rest: config.rest, require_auth: true, }; let app = rpc_app(state); @@ -207,6 +215,8 @@ pub async fn run_rpc( auth: auth.clone(), cookie, work_queue, + rest_queue, + rest: config.rest, require_auth: false, }; let app = rpc_app(state); @@ -265,7 +275,17 @@ fn rpc_app(state: AppState) -> Router { } async fn rest_entry(State(state): State, req: axum::extract::Request) -> Response { - let _permit = match state.work_queue.try_acquire() { + if !state.rest { + return StatusCode::NOT_FOUND.into_response(); + } + // Body first, then the REST queue. A slow client must not hold an RPC slot, + // and REST does not use the RPC work queue. + let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or("").to_string(); + let body = axum::body::to_bytes(req.into_body(), RPC_MAX_HTTP_BODY) + .await + .unwrap_or_default(); + let _permit = match state.rest_queue.try_acquire() { Ok(p) => p, Err(_) => { return ( @@ -275,11 +295,6 @@ async fn rest_entry(State(state): State, req: axum::extract::Request) .into_response(); } }; - let path = req.uri().path().to_string(); - let query = req.uri().query().unwrap_or("").to_string(); - let body = axum::body::to_bytes(req.into_body(), RPC_MAX_HTTP_BODY) - .await - .unwrap_or_default(); let ctx = Arc::clone(&state.ctx); let reply = tokio::task::spawn_blocking(move || { let _g = BlockingRegion::enter(); @@ -803,6 +818,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: true, alert_notify: None, }; @@ -888,6 +904,39 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[tokio::test] + async fn rest_is_404_without_the_flag() { + let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-rest-off").expect("temp dir"); + let q = Arc::new(Query::open_or_create_tiny(dir.join("store")).unwrap()); + let cfg = RpcConfig { + listen: Some("127.0.0.1:0".parse().unwrap()), + socket_path: None, + socket_shared: false, + datadir: dir.path().to_path_buf(), + network: Network::Regtest, + token_path: None, + cookie_path: None, + subversion: None, + work_queue: None, + rest: false, + alert_notify: None, + }; + let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut rest = tokio::net::TcpStream::connect(tcp_addr(&handle)) + .await + .unwrap(); + let get = b"GET /rest/chaininfo.json HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"; + rest.write_all(get).await.unwrap(); + let mut buf = Vec::new(); + rest.read_to_end(&mut buf).await.unwrap(); + let text = String::from_utf8_lossy(&buf); + assert!(text.contains("404"), "REST is off unless --rest: {text}"); + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + async fn post_raw( addr: SocketAddr, auth: &RpcAuth, @@ -937,6 +986,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; @@ -1018,6 +1068,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); @@ -1058,6 +1109,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); @@ -1124,6 +1176,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) @@ -1262,6 +1315,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) @@ -1325,6 +1379,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: Some(1), + rest: false, alert_notify: None, }; @@ -1407,6 +1462,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None) @@ -1577,6 +1633,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc( @@ -1617,6 +1674,7 @@ mod tests { cookie_path: Some(cookie_path.clone()), subversion: None, work_queue: None, + rest: false, alert_notify: None, }; // The cookie is TCP-only: a socket-only listener must not silently ignore it. @@ -1765,6 +1823,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let err = run_rpc(cfg, q, None, None, None, None, None) @@ -1792,6 +1851,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); diff --git a/docs/external_findings/052-livera-review-index.md b/docs/external_findings/052-livera-review-index.md index ad6ed73e2..9e41aabb1 100644 --- a/docs/external_findings/052-livera-review-index.md +++ b/docs/external_findings/052-livera-review-index.md @@ -11,7 +11,7 @@ steps. | H1 | high | Decoy and invalid-type packets skip the rate window | fixed | `decoy_packet_is_handed_to_the_rate_hook` ([055](./055-decoy-rate.md)) | | H2 | high | Inbound eviction drops the longest-connected peer | fixed | `eviction_drops_the_newest_in_the_largest_netgroup` ([060](./060-evict-newest-netgroup.md)) | | H2-ban | high | Misbehavior disconnect is not remembered | fixed | `misbehavior_disconnect_refuses_the_same_address` ([061](./061-misbehavior-remembered.md)) | -| H3 | high | REST always on and shares the RPC work queue | open | — | +| H3 | high | REST always on and shares the RPC work queue | fixed | `rest_is_404_without_the_flag` | | H4 | medium | Silent-payment unsubscribe logs the scan secret | open | — | | H5 | high | IBD reader credits unsolicited data as progress | fixed | `unsolicited_block_does_not_refresh_progress` ([062](./062-ibd-requested-progress.md)) | | N1 | medium | Inv getdata does not charge the send budget | fixed | `inv_getdata_charges_send_budget` ([056](./056-inv-getdata-budget.md)) | diff --git a/docs/lightning.md b/docs/lightning.md index 5bc2613b2..09bb5d22b 100644 --- a/docs/lightning.md +++ b/docs/lightning.md @@ -15,7 +15,7 @@ Fee math: [`mempool-fee-estimation.md`](./mempool-fee-estimation.md). | **CLN** stock `bcli` | `bitcoin-cli` → Core RPC | Methods exist on unix `{datadir}/rpc.sock`. Wrapper: [`scripts/lightning/bitcoin-cli`](../scripts/lightning/bitcoin-cli) (`-datadir=` → `--datadir`). Cookie/TCP `rpcauth` is not the product listen. | | **ldk-node Esplora** | `--esplora-listen` REST | Tip, `/tx/*` (raw/status/outspend/merkleblock-proof), `/fee-estimates`, `POST /tx` work **without** `--sh-index`. Address/scripthash: 503 `scripthash index disabled`. | | **ldk-node Electrum** | `--electrum-listen` TCP | Headers, `transaction.get` / broadcast, `estimatefee` work **without** `--sh-index`. `blockchain.scripthash.*`: JSON-RPC `scripthash index disabled`. TLS is reverse-proxy only (**Q-63**). | -| **ldk-node bitcoind REST** | `--rpc-listen` `GET /rest/block/` | Block bytes, headers, and hash-by-height. TCP `/rest/` is unauthenticated. The BDK wallet still needs Esplora or Electrum with `--sh-index`. | +| **ldk-node bitcoind REST** | `--rpc-listen --rest` `GET /rest/block/` | Block bytes, headers, and hash-by-height. TCP `/rest/` is unauthenticated and off unless `--rest` is set. The BDK wallet still needs Esplora or Electrum with `--sh-index`. | | **LND** | bitcoind + ZMQ or BIP157 | ZMQ stays out. Optional `--block-filter-index` serves BIP158 basic for sealed heights and advertises `NODE_COMPACT_FILTERS` once filters first reach the tip. | `--sh-index` is **not** required to start Electrum/Esplora or for channel @@ -67,8 +67,8 @@ Wrapper [`scripts/lightning/bitcoin-cli`](../scripts/lightning/bitcoin-cli) talk ## LDK / ldk-node ldk-node chain sources: Esplora, Electrum, bitcoind RPC/REST. Esplora and -Electrum are above. Bitcoind REST is `GET /rest/…` on `--rpc-listen` (same -port as JSON-RPC). The BDK wallet on that REST source still needs an +Electrum are above. Bitcoind REST is `GET /rest/…` on `--rpc-listen` when +`--rest` is also set (same port as JSON-RPC). The BDK wallet on that REST source still needs an address index, so point BDK at Esplora or Electrum with `--sh-index`. ### Esplora (`EsploraSyncClient` + BDK) diff --git a/docs/operator/interfaces.md b/docs/operator/interfaces.md index a6a71e5ef..f9b8e5865 100644 --- a/docs/operator/interfaces.md +++ b/docs/operator/interfaces.md @@ -267,7 +267,7 @@ client in rbitcoin's group can connect without traversing the `0700` datadir. (mainnet 8332, testnet 18332, signet 38332, regtest 18443). TCP auth is `Authorization: Bearer` from `{datadir}/rpc.token` (0600), plus optional Core cookie HTTP Basic from `--rpc-cookie-file PATH`. The same -listeners serve Core REST: `GET /rest/chaininfo.json`, block, headers, tx, +listeners serve Core REST when `--rest` is set: `GET /rest/chaininfo.json`, block, headers, tx, mempool, `getutxos`, `deploymentinfo`, and `blockfilter/basic` for heights `--block-filter-index` has sealed. TCP `/rest/` is unauthenticated (Core). `{datadir}/rpc.sock` stays mode 0600 with no HTTP header. See diff --git a/docs/operator/operations.md b/docs/operator/operations.md index 84afb1d1d..99ed1c234 100644 --- a/docs/operator/operations.md +++ b/docs/operator/operations.md @@ -106,6 +106,7 @@ Clean smoke: | `--esplora-block-template` | `esplora_block_template=` | **off** — `GET /block-template` is 404; on = GBT JSON (same as RPC template mode) | | `--rpc` | `rpc=` | **off** — unix JSON-RPC `{datadir}/rpc.sock` (mode 0600) | | `--rpc-listen [ADDR]` | `rpc_listen=` | disabled — implies `--rpc`; omit ADDR → `127.0.0.1` and Core-matching RPC port | +| `--rest` | `rest=` | **off** — unauthenticated `/rest/` on the RPC listener. Own queue. Needs `--rpc` or `--rpc-listen` | | `--rpc-token-file PATH` | `rpc_token_file=` | `{datadir}/rpc.token` (CSPRNG hex; TCP Bearer) | | `--rpc-cookie-file PATH` | `rpc_cookie_file=` | disabled — existing Core `username:password` cookie (no trailing newline) enables TCP HTTP Basic alongside Bearer | | `--rpc-work-queue N` | `rpc_work_queue=` | **16** in-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (array batches still run). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | diff --git a/docs/rpc.md b/docs/rpc.md index 569bd956e..1c4c88b34 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -57,11 +57,12 @@ later Core RPC is present, including descriptor-wallet calls from 0.21 | `--sh-index` | **off** | Class B scripthash (Electrum/Esplora only; RPC by height/hash/txid does not need it) | | `--block-filter-index` | **off** | BIP158 basic. IBD seals them when the flag is on from the start. A later enable still materializes after catch-up. `NODE_COMPACT_FILTERS` is advertised once filters first reach the tip (`getnetworkinfo` lists `COMPACT_FILTERS`), then for the life of the process. `getblockfilter` and `/rest/blockfilter/` serve heights the watermark already covers. Independent of `--sh-index` | | `--rpc-work-queue N` | **16** | In-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (a JSON-RPC array is still one slot). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | +| `--rest` / conf `rest=` | **off** | Unauthenticated `/rest/` on the RPC listener. Own queue, same depth as `--rpc-work-queue`'s default. The body is read before that permit is taken. Without the flag those paths are 404. | TLS is external (reverse proxy). Unix socket needs no HTTP header. TCP is Bearer-authenticated (`{datadir}/rpc.token`) and, only when `--rpc-cookie-file` is configured, also accepts Core cookie HTTP Basic. `GET /rest/…` is on the same -binds. TCP `/rest/` skips Bearer, matching Core. Routes: `chaininfo.json`, +binds only when `--rest` is set. TCP `/rest/` skips Bearer, matching Core. Routes: `chaininfo.json`, `blockhashbyheight/.`, `headers//.*`, `block/.*`, `block/notxdetails/.*`, `tx/.*` (chain and mempool), `mempool/info.json`, `mempool/contents.json`, `getutxos.json` diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 09bb59220..40a02ac89 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -78,6 +78,7 @@ let ++ optional (cfg.coldDataDir != null) cfg.coldDataDir ++ optional cfg.rpc.enable "--rpc-listen" ++ optional cfg.rpc.enable (socket cfg.rpc.address cfg.rpc.port) + ++ optional (cfg.rpc.enable && cfg.rpc.rest) "--rest" ++ optional (cfg.rpc.socketPath != null) "--rpc-socket" ++ optional (cfg.rpc.socketPath != null) cfg.rpc.socketPath ++ optional (cfg.rpc.cookieFile != null) "--rpc-cookie-file" @@ -371,6 +372,16 @@ in ''; }; + rest = mkOption { + type = types.bool; + default = false; + description = '' + Pass `--rest` so unauthenticated `/rest/` is served on the RPC listener. + Off by default. Requires `enable` (TCP). The route uses its own queue, + not the RPC work queue. + ''; + }; + cookieFile = mkOption { type = types.nullOr types.str; default = null; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 8d72af231..c8341ca6c 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -215,6 +215,24 @@ assert builtins.match ".*--datadir-cold /srv/rbitcoin-cold.*" execStart != null; assert builtins.match ".*--network regtest.*" execStart != null; assert builtins.match ".*--listen 127.0.0.1:18444.*" execStart != null; assert builtins.match ".*--rpc-listen 127.0.0.1:18443.*" execStart != null; +assert builtins.match ".*--rest.*" execStart == null; +assert builtins.match ".*--rest.*" ( + nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.rest = true; + }; + } + ]; + } + .config.systemd.services.rbitcoin.serviceConfig.ExecStart +) != null; assert builtins.match ".*--rpc-socket /run/rbitcoin/rpc.sock.*" execStart != null; assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" execStart != null; assert builtins.elem "/run/rbitcoin" service.serviceConfig.ReadWritePaths; From 9de9f5ab95eeefc0afd859d2eb3682dcd9c452e9 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:12:45 -0700 Subject: [PATCH 02/11] electrum: bound silent-payment scans to the recent window A nonzero start height walked from that height through the tip, and the scan kept running after the client hung up. Every start now stays inside the existing 256-block window, and each chunk checks the socket once before it runs. --- changelog.d/rest-electrum.md | 3 + crates/rbitcoin-electrum/src/server.rs | 42 ++++++++++++-- crates/rbitcoin-electrum/src/server_tests.rs | 58 +++++++++++++++++++ crates/rbitcoin-electrum/src/silent_scan.rs | 12 ++-- .../052-livera-review-index.md | 2 +- 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md index fe8d5ce5c..728615dcb 100644 --- a/changelog.d/rest-electrum.md +++ b/changelog.d/rest-electrum.md @@ -2,3 +2,6 @@ Security - `/rest/` on the RPC listener is off unless `--rest` or `rest=` is set. It uses its own queue, and the body is read before that permit is taken. +- A silent-payment subscribe scans at most the recent 256-block window, + including when the client passes a start height. The scan stops when + the client hangs up. diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index 9743425e6..c9e9bdc0b 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -17,7 +17,7 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::{broadcast, Notify, Semaphore}; use tokio::task::JoinHandle; @@ -570,6 +570,7 @@ where let params_v = req.get("params").cloned().unwrap_or(json!([])); if method == "blockchain.silentpayments.subscribe" { serve_sp_subscribe( + &mut reader, &mut writer, &query, ¶ms, @@ -736,7 +737,8 @@ where /// Tweaks stream: JSON-RPC result = first height, then one notify per /// following height, then `{"message":"done"}`. Honor `count` through tip. /// Answer `server.ping` while computing. -async fn serve_sp_subscribe( +async fn serve_sp_subscribe( + reader: &mut R, writer: &mut W, query: &Arc, chain: &Arc, @@ -746,6 +748,7 @@ async fn serve_sp_subscribe( conn: &mut ElectrumConn, ) -> Result<(), std::io::Error> where + R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin, { let tip = query.tip_height().map(|h| h.0); @@ -780,7 +783,8 @@ where write_line(writer, &rpc_result(&id, &result, None)).await?; conn.sp_scan_busy = true; let last = tip.unwrap_or(sub.start); - let hits = scan_sp_off_connection(query, chain, &sub, last).await; + let scanned = scan_sp_off_connection(reader, query, chain, &sub, last).await; + let hits = scanned.hits; conn.sp_scan_busy = false; let note = json!({ "jsonrpc": "2.0", @@ -899,16 +903,41 @@ fn sp_scan_ranges(start: u32, last: u32) -> Vec<(u32, u32)> { ranges } -async fn scan_sp_off_connection( +struct SpScan { + hits: Vec, + /// Chunks scanned before the client hung up. + chunks: usize, +} + +/// One poll. `fill_buf` does not consume: an empty ready buffer is EOF, +/// and pending means the client is still connected. A zero timeout can +/// poll twice, so this does not use one. +fn peer_hung_up(reader: &mut R) -> bool { + let waker = std::task::Waker::noop(); + let mut cx = std::task::Context::from_waker(&waker); + let mut fut = std::pin::pin!(reader.fill_buf()); + match std::future::Future::poll(fut.as_mut(), &mut cx) { + std::task::Poll::Ready(Ok(buf)) => buf.is_empty(), + std::task::Poll::Ready(Err(_)) => true, + std::task::Poll::Pending => false, + } +} + +async fn scan_sp_off_connection( + reader: &mut R, query: &Arc, chain: &Arc, sub: &crate::silent_scan::SpSub, last: u32, -) -> Vec { +) -> SpScan { use crate::silent_scan::SP_SCAN_PERMITS; static PERMITS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(SP_SCAN_PERMITS); let mut hits = Vec::new(); + let mut chunks = 0usize; for (from, end) in sp_scan_ranges(sub.start, last) { + if peer_hung_up(reader) { + break; + } let Ok(permit) = PERMITS.acquire().await else { break; }; @@ -923,8 +952,9 @@ async fn scan_sp_off_connection( .unwrap_or_default(); drop(permit); hits.extend(chunk); + chunks += 1; } - hits + SpScan { hits, chunks } } #[allow(clippy::too_many_arguments)] // matches handle_client call-site diff --git a/crates/rbitcoin-electrum/src/server_tests.rs b/crates/rbitcoin-electrum/src/server_tests.rs index 2371aa0ec..346e29be6 100644 --- a/crates/rbitcoin-electrum/src/server_tests.rs +++ b/crates/rbitcoin-electrum/src/server_tests.rs @@ -1592,4 +1592,62 @@ fn sp_scan_ranges_cover_one_height_and_the_next_chunk() { assert_eq!(super::sp_scan_ranges(0, 1), vec![(0, 1)]); } +#[tokio::test(flavor = "current_thread")] +async fn sp_scan_stops_when_the_client_hangs_up() { + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::io::{AsyncBufRead, AsyncRead, ReadBuf}; + + struct HangUp { + polls: std::cell::Cell, + } + impl AsyncRead for HangUp { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Pending + } + } + impl AsyncBufRead for HangUp { + fn poll_fill_buf( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + let n = self.polls.get(); + self.polls.set(n + 1); + if n == 0 { + Poll::Pending + } else { + Poll::Ready(Ok(&[])) + } + } + fn consume(self: Pin<&mut Self>, _amt: usize) {} + } + + let (_dir, q) = tmp_store(); + let q = std::sync::Arc::new(q); + let chain = std::sync::Arc::new(ChainParams::regtest()); + let scan = "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c"; + let spend = "025cc9856d6f8375350e123978daac200c260cb5b5ae83106cab90484dcd8fcf36"; + let sub = crate::silent_scan::parse_sub( + &json!([scan, spend, 0]), + bitcoin::Network::Regtest, + Some(crate::silent_scan::SP_SCAN_CHUNK), + ) + .unwrap(); + let last = crate::silent_scan::SP_SCAN_CHUNK; + let ranges = super::sp_scan_ranges(sub.start, last); + assert_eq!(ranges.len(), 2, "the fixture spans two chunks"); + let mut reader = HangUp { + polls: std::cell::Cell::new(0), + }; + let scanned = super::scan_sp_off_connection(&mut reader, &q, &chain, &sub, last).await; + assert_eq!( + scanned.chunks, 1, + "a hang-up before the next chunk stops the scan" + ); +} + include!("electrum_sh_journey.rs"); diff --git a/crates/rbitcoin-electrum/src/silent_scan.rs b/crates/rbitcoin-electrum/src/silent_scan.rs index d1379bc36..46ba394be 100644 --- a/crates/rbitcoin-electrum/src/silent_scan.rs +++ b/crates/rbitcoin-electrum/src/silent_scan.rs @@ -66,11 +66,10 @@ pub fn parse_sub(params: &Value, network: Network, tip: Option) -> Result start.max(tip_h.saturating_sub(SP_HISTORY_WINDOW)), + None => start, }; let address = encode_sp_address(network, &scan, &spend); Ok(SpSub { @@ -198,6 +197,9 @@ mod tests { assert_eq!(null_start.start, 0); let bounded = parse_sub(&json!([scan, spend, 0]), Network::Regtest, Some(1_000)).unwrap(); assert_eq!(bounded.start, 1_000 - SP_HISTORY_WINDOW); + let wide = parse_sub(&json!([scan, spend, 1]), Network::Regtest, Some(10_000)).unwrap(); + assert_eq!(wide.start, 10_000 - SP_HISTORY_WINDOW); + assert!(10_000 - wide.start <= SP_HISTORY_WINDOW); let bad_spend = match parse_sub(&json!([scan, "02"]), Network::Regtest, Some(0)) { Err(e) => e, Ok(_) => panic!("spend"), diff --git a/docs/external_findings/052-livera-review-index.md b/docs/external_findings/052-livera-review-index.md index 9e41aabb1..7ff938522 100644 --- a/docs/external_findings/052-livera-review-index.md +++ b/docs/external_findings/052-livera-review-index.md @@ -16,7 +16,7 @@ steps. | H5 | high | IBD reader credits unsolicited data as progress | fixed | `unsolicited_block_does_not_refresh_progress` ([062](./062-ibd-requested-progress.md)) | | N1 | medium | Inv getdata does not charge the send budget | fixed | `inv_getdata_charges_send_budget` ([056](./056-inv-getdata-budget.md)) | | M1 | medium | Block getdata can queue past the send budget | fixed | `getdata_stops_when_send_budget_is_already_over` ([057](./057-block-getdata-budget.md)) | -| M2 | medium | Silent-payment scan span is unbounded when start is set | open | — | +| M2 | medium | Silent-payment scan span is unbounded when start is set | fixed | `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up` | | M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | open | — | | M4 | medium | fuse8 segment length need not be a power of two | open | — | | M5 | medium | Class A bulk read ignores the published end | open | — | From f6075f5d5d408b227ec0c7115b3f9db15bd2cada Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:20:29 -0700 Subject: [PATCH 03/11] electrum: skip the history line when the scan client is already gone The chunk count was only read by the test, so a library build rejected it. The subscribe path now uses that count: if the client hung up before any chunk, it does not write a history notification. --- crates/rbitcoin-electrum/src/server.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index c9e9bdc0b..c568bf098 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -786,6 +786,10 @@ where let scanned = scan_sp_off_connection(reader, query, chain, &sub, last).await; let hits = scanned.hits; conn.sp_scan_busy = false; + // The client left before any chunk. A history line would write into a closed socket. + if scanned.chunks == 0 && sub.start <= last { + return Ok(()); + } let note = json!({ "jsonrpc": "2.0", "method": "blockchain.silentpayments.subscribe", From 2e31c74d047363d9486991b20dc1e53fdb698b79 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:20:33 -0700 Subject: [PATCH 04/11] rpc: cap waits and keep long-polls off the work queue A waitfor* call held the only work-queue permit for the whole sleep, so other RPC calls got 503. The permit is taken after the wait. Requested timeouts and getblocktemplate long-poll stop at two minutes, and the listener uses a 256-connection cap and the same request timeout. --- changelog.d/rest-electrum.md | 2 + crates/rbitcoin-rpc/src/methods/chain.rs | 18 +++- crates/rbitcoin-rpc/src/methods/mod.rs | 3 + crates/rbitcoin-rpc/src/server.rs | 100 ++++++++++++++++-- .../052-livera-review-index.md | 2 +- 5 files changed, 113 insertions(+), 12 deletions(-) diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md index 728615dcb..27b73e300 100644 --- a/changelog.d/rest-electrum.md +++ b/changelog.d/rest-electrum.md @@ -5,3 +5,5 @@ Security - A silent-payment subscribe scans at most the recent 256-block window, including when the client passes a start height. The scan stops when the client hangs up. +- RPC waits are capped at two minutes, the listener accepts at most 256 + connections, and a long-poll does not hold a work-queue slot. diff --git a/crates/rbitcoin-rpc/src/methods/chain.rs b/crates/rbitcoin-rpc/src/methods/chain.rs index 1c8f58e76..15457f6b7 100644 --- a/crates/rbitcoin-rpc/src/methods/chain.rs +++ b/crates/rbitcoin-rpc/src/methods/chain.rs @@ -1111,7 +1111,8 @@ pub(crate) fn getchaintips(ctx: &RpcContext, params: &RpcParams) -> Result Result { - Ok(params.opt_u64(idx, name)?.unwrap_or(30_000)) + let ms = params.opt_u64(idx, name)?.unwrap_or(30_000); + Ok(ms.min(super::RPC_WAIT_TIMEOUT_MS)) } pub(crate) fn tip_hash_height(ctx: &RpcContext) -> Result<(String, u32), Value> { @@ -1312,3 +1313,18 @@ mod core_double_tests { assert_eq!(format_core_double(132757073449487.52), "132757073449487.5"); } } + +#[cfg(test)] +mod wait_tests { + use super::*; + + #[test] + fn wait_timeout_ms_caps_at_two_minutes() { + let huge = RpcParams::positional(vec![json!(500_000)]); + assert_eq!(wait_timeout_ms(&huge, 0, "timeout").unwrap(), 120_000); + let absent = RpcParams::positional(vec![]); + assert_eq!(wait_timeout_ms(&absent, 0, "timeout").unwrap(), 30_000); + let short = RpcParams::positional(vec![json!(50)]); + assert_eq!(wait_timeout_ms(&short, 0, "timeout").unwrap(), 50); + } +} diff --git a/crates/rbitcoin-rpc/src/methods/mod.rs b/crates/rbitcoin-rpc/src/methods/mod.rs index f5d3dd532..d931ff30d 100644 --- a/crates/rbitcoin-rpc/src/methods/mod.rs +++ b/crates/rbitcoin-rpc/src/methods/mod.rs @@ -15,6 +15,9 @@ use std::time::Instant; pub use chain::difficulty_rpc_f64; pub(crate) use chain::{tip_hash_height, wait_timeout_ms}; + +/// Cap for `waitfor*` and `getblocktemplate` long-poll. Not a knob. +pub(crate) const RPC_WAIT_TIMEOUT_MS: u64 = 120_000; pub(crate) use mine::gbt_longpoll_id; pub use mine::{gbt_template, submit_received_block}; pub(crate) use rest::{dispatch_rest, RestReply}; diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index cee5c6d64..d1cae03c9 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -11,11 +11,15 @@ use axum::middleware::{from_fn_with_state, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; +use tower::limit::ConcurrencyLimitLayer; +use tower_http::timeout::TimeoutLayer; /// Axum's default request-body cap, named so auth and 413 share one limit. pub const RPC_MAX_HTTP_BODY: usize = 2 * 1024 * 1024; /// Core `-rpcworkqueue`. Omitted or `0` is this finite queue. pub const DEFAULT_RPC_WORK_QUEUE: usize = 16; +/// Accept cap, copied from the Electrum public listener. Not a knob. +const RPC_MAX_CONNECTIONS: usize = 256; use rbitcoin_log::info; use rbitcoin_net::{BlockingRegion, MempoolHub}; use rbitcoin_primitives::Network; @@ -271,6 +275,11 @@ fn rpc_app(state: AppState) -> Router { .route("/rest/{*path}", get(rest_entry).post(rest_entry)) .layer(DefaultBodyLimit::max(RPC_MAX_HTTP_BODY)) .layer(from_fn_with_state(state.clone(), reject_unauthorized)) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + std::time::Duration::from_millis(crate::methods::RPC_WAIT_TIMEOUT_MS), + )) + .layer(ConcurrencyLimitLayer::new(RPC_MAX_CONNECTIONS)) .with_state(state) } @@ -373,7 +382,12 @@ async fn satisfy_http_wait( rbitcoin_log::info!("ThreadRPCServer method=getblocktemplate"); let _active = crate::methods::ActiveCall::enter(&ctx.active, method); let ctx = Arc::clone(ctx); + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_millis(crate::methods::RPC_WAIT_TIMEOUT_MS); loop { + if ctx.stop.load(Ordering::SeqCst) || tokio::time::Instant::now() >= deadline { + return true; + } let ready = { let ctx = Arc::clone(&ctx); let want = want.clone(); @@ -383,11 +397,14 @@ async fn satisfy_http_wait( .await .unwrap_or(true) }; - if ready { + if ready || tokio::time::Instant::now() >= deadline { return true; } + let slice = deadline + .saturating_duration_since(tokio::time::Instant::now()) + .min(std::time::Duration::from_millis(50)); tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {} + _ = tokio::time::sleep(slice) => {} _ = recv_tip(&mut tips) => {} } } @@ -475,6 +492,15 @@ async fn recv_tip(tips: &mut Option, body: Bytes) -> Response { + let parsed: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => { + return parse_error_response(); + } + }; + let ctx = Arc::clone(&state.ctx); + // The long-poll must not sit on the only work-queue slot. + let waited = satisfy_http_wait(&ctx, &parsed).await; let _permit = match state.work_queue.try_acquire() { Ok(p) => p, Err(_) => { @@ -485,14 +511,6 @@ async fn rpc_post(State(state): State, body: Bytes) -> Response { .into_response(); } }; - let parsed: serde_json::Value = match serde_json::from_slice(&body) { - Ok(v) => v, - Err(_) => { - return parse_error_response(); - } - }; - let ctx = Arc::clone(&state.ctx); - let waited = satisfy_http_wait(&ctx, &parsed).await; let joined = tokio::task::spawn_blocking(move || { let _g = BlockingRegion::enter(); if waited { @@ -1363,6 +1381,68 @@ mod tests { }); } + #[tokio::test] + async fn long_poll_does_not_hold_the_work_queue() { + let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-wait-queue").expect("dir"); + let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); + let hub = rbitcoin_net::ChainHub::new( + q, + rbitcoin_consensus::ChainParams::regtest(), + rbitcoin_consensus::Milestone::NONE, + ); + hub.ensure_genesis().unwrap(); + let query = Arc::clone(&hub.query); + let cfg = RpcConfig { + listen: Some("127.0.0.1:0".parse().unwrap()), + socket_path: None, + socket_shared: false, + datadir: dir.path().to_path_buf(), + network: Network::Regtest, + token_path: None, + cookie_path: None, + subversion: None, + work_queue: Some(1), + rest: false, + alert_notify: None, + }; + let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let addr = tcp_addr(&handle); + let auth = handle.auth.clone(); + let waiter = tokio::spawn(async move { + let body = serde_json::json!({ + "jsonrpc": "1.0", + "id": "w", + "method": "waitfornewblock", + "params": [5_000] + }) + .to_string(); + let _ = post_raw(addr, &auth, body.as_bytes()).await; + }); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + let (st, body) = tokio::time::timeout( + std::time::Duration::from_secs(2), + post_raw( + tcp_addr(&handle), + &handle.auth, + br#"{"jsonrpc":"1.0","id":1,"method":"getblockcount"}"#, + ), + ) + .await + .expect("getblockcount while a long-poll is in flight"); + assert_eq!(st, 200, "{body:?}"); + assert_eq!(body.expect("json")["result"], 0); + assert!( + !waiter.is_finished(), + "waitfornewblock returned before the queue probe" + ); + waiter.abort(); + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn rpc_work_queue_exceeded() { let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-wq").expect("temp dir"); diff --git a/docs/external_findings/052-livera-review-index.md b/docs/external_findings/052-livera-review-index.md index 7ff938522..31c9c3738 100644 --- a/docs/external_findings/052-livera-review-index.md +++ b/docs/external_findings/052-livera-review-index.md @@ -17,7 +17,7 @@ steps. | N1 | medium | Inv getdata does not charge the send budget | fixed | `inv_getdata_charges_send_budget` ([056](./056-inv-getdata-budget.md)) | | M1 | medium | Block getdata can queue past the send budget | fixed | `getdata_stops_when_send_budget_is_already_over` ([057](./057-block-getdata-budget.md)) | | M2 | medium | Silent-payment scan span is unbounded when start is set | fixed | `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up` | -| M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | open | — | +| M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | fixed | `long_poll_does_not_hold_the_work_queue`, `wait_timeout_ms_caps_at_two_minutes` | | M4 | medium | fuse8 segment length need not be a power of two | open | — | | M5 | medium | Class A bulk read ignores the published end | open | — | | M6 | medium | Testnet milestone is height-only | open | — | From 3ffa094756b3a4439c161dc0857205d053727989 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:24:59 -0700 Subject: [PATCH 05/11] electrum: pass the noop waker without an extra borrow Clippy rejects `&waker` because `Waker::noop` already returns a reference. --- crates/rbitcoin-electrum/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index c568bf098..775bb219d 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -918,7 +918,7 @@ struct SpScan { /// poll twice, so this does not use one. fn peer_hung_up(reader: &mut R) -> bool { let waker = std::task::Waker::noop(); - let mut cx = std::task::Context::from_waker(&waker); + let mut cx = std::task::Context::from_waker(waker); let mut fut = std::pin::pin!(reader.fill_buf()); match std::future::Future::poll(fut.as_mut(), &mut cx) { std::task::Poll::Ready(Ok(buf)) => buf.is_empty(), From 6a290d916ad866df9858337989738e4bdb71cb75 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:25:02 -0700 Subject: [PATCH 06/11] log: redact scan secrets and extended private keys Silent-payment unsubscribe and scantxoutset logged the scan key and xprv/tprv text. api_call strips those before the trace line and the JSONL file, so every caller is covered. --- changelog.d/rest-electrum.md | 1 + crates/rbitcoin-log/src/api_log.rs | 125 +++++++++++++++++- .../052-livera-review-index.md | 2 +- 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md index 27b73e300..132ff32d6 100644 --- a/changelog.d/rest-electrum.md +++ b/changelog.d/rest-electrum.md @@ -7,3 +7,4 @@ Security the client hangs up. - RPC waits are capped at two minutes, the listener accepts at most 256 connections, and a long-poll does not hold a work-queue slot. +- API logs strip `xprv` / `tprv` material and silent-payment scan secrets. diff --git a/crates/rbitcoin-log/src/api_log.rs b/crates/rbitcoin-log/src/api_log.rs index d3577b9ca..93b9cda29 100644 --- a/crates/rbitcoin-log/src/api_log.rs +++ b/crates/rbitcoin-log/src/api_log.rs @@ -54,8 +54,8 @@ fn compact_params(params: &str) -> String { /// Record one Electrum / Esplora / RPC call. /// -/// `params` should already be compact (see [`compact_params`]). `err` is -/// `None` on success. +/// Extended private keys and silent-payment scan secrets are stripped here, +/// then the params blob is compacted. `err` is `None` on success. pub fn api_call( surface: &str, peer: &str, @@ -64,7 +64,7 @@ pub fn api_call( wall_ms: u64, err: Option<&str>, ) { - let params = compact_params(params); + let params = compact_params(&redact_secrets(method, params)); match err { None => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} ok"), Some(e) => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} err={e}"), @@ -91,6 +91,75 @@ pub fn api_call( let _ = file.flush(); } +fn redact_secrets(method: &str, params: &str) -> String { + let out = redact_ext_privkeys(params); + if method.contains("silentpayment") { + redact_quoted_hex64(&out) + } else { + out + } +} + +fn is_base58(b: u8) -> bool { + matches!( + b, + b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z' + ) +} + +fn is_hex(b: u8) -> bool { + b.is_ascii_hexdigit() +} + +/// `xprv` / `tprv` / `yprv` / `zprv` plus the following base58 key material. +fn redact_ext_privkeys(s: &str) -> String { + let b = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < b.len() { + if i + 4 <= b.len() { + let tag = &b[i..i + 4]; + if matches!(tag, b"xprv" | b"tprv" | b"yprv" | b"zprv") { + let mut j = i + 4; + while j < b.len() && is_base58(b[j]) { + j += 1; + } + if j > i + 4 { + out.push_str(""); + i = j; + continue; + } + } + } + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +/// A quoted 64-hex string is a silent-payment scan secret on that method. +fn redact_quoted_hex64(s: &str) -> String { + let b = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'"' + && i + 65 < b.len() + && b[i + 65] == b'"' + && b[i + 1..i + 65].iter().copied().all(is_hex) + { + out.push_str("\"\""); + i += 66; + continue; + } + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { @@ -189,4 +258,54 @@ mod tests { fn json_escape_quotes() { assert_eq!(json_escape("a\"b\\c"), "a\\\"b\\\\c"); } + + #[test] + fn api_call_redacts_scan_secrets_and_ext_privkeys() { + let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let scan = "ab".repeat(32); + let xprv = format!("xprv{}", "1".repeat(40)); + let tprv = format!("tprv{}", "A".repeat(20)); + let path = std::env::temp_dir().join(format!( + "rbitcoin-api-redact-{}-{}.jsonl", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_file(&path); + init_api_log(&path).unwrap(); + crate::capture_logs(true); + api_call( + "electrum", + "127.0.0.1:1", + "blockchain.silentpayments.unsubscribe", + &format!("[\"{scan}\",\"02ff\",1]"), + 4, + None, + ); + api_call( + "rpc", + "-", + "scantxoutset", + &format!("[\"start\",[\"{xprv}\",\"{tprv}\"]]"), + 5, + None, + ); + let logs = crate::take_logs(); + crate::capture_logs(false); + close_api_log(); + let body = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let trace = logs + .iter() + .map(|(_, msg)| msg.clone()) + .collect::>() + .join("\n"); + let all = format!("{body}\n{trace}"); + assert!(!all.contains(&scan), "{all}"); + assert!(!all.contains(&xprv), "{all}"); + assert!(!all.contains(&tprv), "{all}"); + assert!(all.contains(""), "{all}"); + } } diff --git a/docs/external_findings/052-livera-review-index.md b/docs/external_findings/052-livera-review-index.md index 31c9c3738..8b04800b1 100644 --- a/docs/external_findings/052-livera-review-index.md +++ b/docs/external_findings/052-livera-review-index.md @@ -12,7 +12,7 @@ steps. | H2 | high | Inbound eviction drops the longest-connected peer | fixed | `eviction_drops_the_newest_in_the_largest_netgroup` ([060](./060-evict-newest-netgroup.md)) | | H2-ban | high | Misbehavior disconnect is not remembered | fixed | `misbehavior_disconnect_refuses_the_same_address` ([061](./061-misbehavior-remembered.md)) | | H3 | high | REST always on and shares the RPC work queue | fixed | `rest_is_404_without_the_flag` | -| H4 | medium | Silent-payment unsubscribe logs the scan secret | open | — | +| H4 | medium | Silent-payment unsubscribe logs the scan secret | fixed | `api_call_redacts_scan_secrets_and_ext_privkeys` | | H5 | high | IBD reader credits unsolicited data as progress | fixed | `unsolicited_block_does_not_refresh_progress` ([062](./062-ibd-requested-progress.md)) | | N1 | medium | Inv getdata does not charge the send budget | fixed | `inv_getdata_charges_send_budget` ([056](./056-inv-getdata-budget.md)) | | M1 | medium | Block getdata can queue past the send budget | fixed | `getdata_stops_when_send_budget_is_already_over` ([057](./057-block-getdata-budget.md)) | From fa13cc544fab43653afc5648a18b4eaa8d7bec9c Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:26:40 -0700 Subject: [PATCH 07/11] docs: write up the REST and Electrum Livera rows The index links 063-066 for REST, log redaction, the scan window, and RPC wait limits. --- changelog.d/rest-electrum.md | 1 + docs/external_findings/052-livera-review-index.md | 8 ++++---- docs/external_findings/063-rest-own-queue.md | 9 +++++++++ docs/external_findings/064-api-log-redaction.md | 9 +++++++++ docs/external_findings/065-sp-scan-window.md | 9 +++++++++ docs/external_findings/066-rpc-wait-cap.md | 9 +++++++++ docs/external_findings/README.md | 4 ++++ 7 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 docs/external_findings/063-rest-own-queue.md create mode 100644 docs/external_findings/064-api-log-redaction.md create mode 100644 docs/external_findings/065-sp-scan-window.md create mode 100644 docs/external_findings/066-rpc-wait-cap.md diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md index 132ff32d6..93dd7f627 100644 --- a/changelog.d/rest-electrum.md +++ b/changelog.d/rest-electrum.md @@ -8,3 +8,4 @@ Security - RPC waits are capped at two minutes, the listener accepts at most 256 connections, and a long-poll does not hold a work-queue slot. - API logs strip `xprv` / `tprv` material and silent-payment scan secrets. +- Findings write-ups: 063, 064, 065, 066. diff --git a/docs/external_findings/052-livera-review-index.md b/docs/external_findings/052-livera-review-index.md index 8b04800b1..d7a8567a1 100644 --- a/docs/external_findings/052-livera-review-index.md +++ b/docs/external_findings/052-livera-review-index.md @@ -11,13 +11,13 @@ steps. | H1 | high | Decoy and invalid-type packets skip the rate window | fixed | `decoy_packet_is_handed_to_the_rate_hook` ([055](./055-decoy-rate.md)) | | H2 | high | Inbound eviction drops the longest-connected peer | fixed | `eviction_drops_the_newest_in_the_largest_netgroup` ([060](./060-evict-newest-netgroup.md)) | | H2-ban | high | Misbehavior disconnect is not remembered | fixed | `misbehavior_disconnect_refuses_the_same_address` ([061](./061-misbehavior-remembered.md)) | -| H3 | high | REST always on and shares the RPC work queue | fixed | `rest_is_404_without_the_flag` | -| H4 | medium | Silent-payment unsubscribe logs the scan secret | fixed | `api_call_redacts_scan_secrets_and_ext_privkeys` | +| H3 | high | REST always on and shares the RPC work queue | fixed | `rest_is_404_without_the_flag` ([063](./063-rest-own-queue.md)) | +| H4 | medium | Silent-payment unsubscribe logs the scan secret | fixed | `api_call_redacts_scan_secrets_and_ext_privkeys` ([064](./064-api-log-redaction.md)) | | H5 | high | IBD reader credits unsolicited data as progress | fixed | `unsolicited_block_does_not_refresh_progress` ([062](./062-ibd-requested-progress.md)) | | N1 | medium | Inv getdata does not charge the send budget | fixed | `inv_getdata_charges_send_budget` ([056](./056-inv-getdata-budget.md)) | | M1 | medium | Block getdata can queue past the send budget | fixed | `getdata_stops_when_send_budget_is_already_over` ([057](./057-block-getdata-budget.md)) | -| M2 | medium | Silent-payment scan span is unbounded when start is set | fixed | `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up` | -| M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | fixed | `long_poll_does_not_hold_the_work_queue`, `wait_timeout_ms_caps_at_two_minutes` | +| M2 | medium | Silent-payment scan span is unbounded when start is set | fixed | `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up` ([065](./065-sp-scan-window.md)) | +| M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | fixed | `long_poll_does_not_hold_the_work_queue`, `wait_timeout_ms_caps_at_two_minutes` ([066](./066-rpc-wait-cap.md)) | | M4 | medium | fuse8 segment length need not be a power of two | open | — | | M5 | medium | Class A bulk read ignores the published end | open | — | | M6 | medium | Testnet milestone is height-only | open | — | diff --git a/docs/external_findings/063-rest-own-queue.md b/docs/external_findings/063-rest-own-queue.md new file mode 100644 index 000000000..5dbf4b842 --- /dev/null +++ b/docs/external_findings/063-rest-own-queue.md @@ -0,0 +1,9 @@ +# 063 — REST is off unless --rest is set + +**Severity:** high +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (H3) + +REST on the RPC listener was always on, shared the RPC work queue, and took a permit before the body was read. REST is off unless `--rest` or `rest=` is set, uses its own queue of the same depth, and reads the body before taking a permit. A full REST queue is HTTP 503. NixOS `services.rbitcoin.rpc.rest` defaults to false and passes `--rest` only when RPC and that option are on. + +**Regression:** `rbitcoin-rpc` `rest_is_404_without_the_flag`. diff --git a/docs/external_findings/064-api-log-redaction.md b/docs/external_findings/064-api-log-redaction.md new file mode 100644 index 000000000..e200d2147 --- /dev/null +++ b/docs/external_findings/064-api-log-redaction.md @@ -0,0 +1,9 @@ +# 064 — API logs redact scan secrets and extended keys + +**Severity:** medium +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (H4) + +Silent-payment unsubscribe and generic RPC `api_call` wrote the scan secret and extended private keys to the API log. Those values are redacted before the line is written. The method name stays. + +**Regression:** `rbitcoin-rpc` `api_call_redacts_scan_secrets_and_ext_privkeys`. diff --git a/docs/external_findings/065-sp-scan-window.md b/docs/external_findings/065-sp-scan-window.md new file mode 100644 index 000000000..34937c5fa --- /dev/null +++ b/docs/external_findings/065-sp-scan-window.md @@ -0,0 +1,9 @@ +# 065 — Silent-payment scan stays inside 256 blocks + +**Severity:** medium +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (M2) + +A silent-payment scan whose start height was not zero walked from there to the tip and kept running after the client disconnected. Every scan is at most the existing 256-height window at the tip, including a nonzero start, and it stops when the connection ends. The scan uses the tweak index. It does not require `--sh-index`. + +**Regression:** `rbitcoin-electrum` `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up`. diff --git a/docs/external_findings/066-rpc-wait-cap.md b/docs/external_findings/066-rpc-wait-cap.md new file mode 100644 index 000000000..3efef8adf --- /dev/null +++ b/docs/external_findings/066-rpc-wait-cap.md @@ -0,0 +1,9 @@ +# 066 — RPC accept times out and long-polls release the permit + +**Severity:** medium +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (M3) + +The RPC listener had no accept timeout, and a long-poll held a work-queue permit for the whole wait. Accepts time out at two minutes, the listener caps connections, and the long-poll takes a permit only after the wait. + +**Regression:** `rbitcoin-rpc` `long_poll_does_not_hold_the_work_queue`, `wait_timeout_ms_caps_at_two_minutes`. diff --git a/docs/external_findings/README.md b/docs/external_findings/README.md index 8b1d6609d..30a77349f 100644 --- a/docs/external_findings/README.md +++ b/docs/external_findings/README.md @@ -67,6 +67,10 @@ rbitcoin reference, or redteam static analysis). Numbered reports live beside th | [060](./060-evict-newest-netgroup.md) | high | Evict the newest inbound in the largest netgroup | fixed | `eviction_drops_the_newest_in_the_largest_netgroup` | | [061](./061-misbehavior-remembered.md) | high | Misbehavior disconnect is remembered in memory | fixed | `misbehavior_disconnect_refuses_the_same_address` | | [062](./062-ibd-requested-progress.md) | high | Only a requested block moves the IBD stall clock | fixed | `unsolicited_block_does_not_refresh_progress` | +| [063](./063-rest-own-queue.md) | high | REST is off unless --rest is set | fixed | `rest_is_404_without_the_flag` | +| [064](./064-api-log-redaction.md) | medium | API logs redact scan secrets and extended keys | fixed | `api_call_redacts_scan_secrets_and_ext_privkeys` | +| [065](./065-sp-scan-window.md) | medium | Silent-payment scan stays inside 256 blocks | fixed | `parse_sub_labels_start_and_networks` | +| [066](./066-rpc-wait-cap.md) | medium | RPC accept times out and long-polls release the permit | fixed | `long_poll_does_not_hold_the_work_queue` | **012–021:** fuzzamoto differential report (`rbitcoin-report.tar.gz`, baseline `8f3990f`). Report-local 001–010 are **renumbered** here. Identity/BIP30 From 0addb9cd2574e838728c8f1531c7f3b07106c6f1 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:17:44 -0700 Subject: [PATCH 08/11] rpc: return long-poll bodies and drop extra accepts A tower timeout around the router turned a capped waitfor or getblocktemplate into an empty 408. The two-minute cap stays in the handler, which still writes the JSON-RPC body. Extra TCP accepts are dropped the way the Electrum listener drops them, instead of waiting outside that cap. --- Cargo.lock | 2 - changelog.d/rest-electrum.md | 6 +- crates/rbitcoin-node/src/config.rs | 4 +- crates/rbitcoin-rpc/Cargo.toml | 2 - crates/rbitcoin-rpc/src/server.rs | 241 ++++++++++++++++++++++++++--- 5 files changed, 225 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64707f78a..0d2179296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -820,8 +820,6 @@ dependencies = [ "serde", "serde_json", "tokio", - "tower", - "tower-http", ] [[package]] diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md index 93dd7f627..cfbe4ab85 100644 --- a/changelog.d/rest-electrum.md +++ b/changelog.d/rest-electrum.md @@ -5,7 +5,9 @@ Security - A silent-payment subscribe scans at most the recent 256-block window, including when the client passes a start height. The scan stops when the client hangs up. -- RPC waits are capped at two minutes, the listener accepts at most 256 - connections, and a long-poll does not hold a work-queue slot. +- RPC waits are capped at two minutes inside the handler, so a long-poll + that hits the cap still returns a JSON-RPC body. The listener drops a + new connection once 256 are open. A long-poll does not hold a + work-queue slot. - API logs strip `xprv` / `tprv` material and silent-payment scan secrets. - Findings write-ups: 063, 064, 065, 066. diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 75ca0c54e..d2484c87b 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -621,7 +621,9 @@ impl NodeConfig { return Err(NodeError::Config("--metrics needs --health-listen".into())); } if self.rpc.rest && self.rpc.listen.is_none() && !self.rpc.socket { - return Err(NodeError::Config("--rest needs --rpc or --rpc-listen".into())); + return Err(NodeError::Config( + "--rest needs --rpc or --rpc-listen".into(), + )); } self.validate_only_net()?; self.validate_hidden_inbound()?; diff --git a/crates/rbitcoin-rpc/Cargo.toml b/crates/rbitcoin-rpc/Cargo.toml index ea9774714..f5532d74b 100644 --- a/crates/rbitcoin-rpc/Cargo.toml +++ b/crates/rbitcoin-rpc/Cargo.toml @@ -17,8 +17,6 @@ rbitcoin-consensus = { workspace = true } bitcoin = { workspace = true } miniscript = { workspace = true } axum = { workspace = true } -tower = { workspace = true } -tower-http = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["arbitrary_precision"] } diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index d1cae03c9..49040dd0f 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -11,8 +11,6 @@ use axum::middleware::{from_fn_with_state, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; -use tower::limit::ConcurrencyLimitLayer; -use tower_http::timeout::TimeoutLayer; /// Axum's default request-body cap, named so auth and 413 share one limit. pub const RPC_MAX_HTTP_BODY: usize = 2 * 1024 * 1024; @@ -26,10 +24,14 @@ use rbitcoin_primitives::Network; use rbitcoin_query::Query; use std::net::SocketAddr; use std::path::PathBuf; +use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +use std::task::{Context, Poll}; use std::time::Instant; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::TcpListener; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; #[cfg(unix)] fn bind_unix_mode(path: &std::path::Path, mode: u32) -> std::io::Result { @@ -40,6 +42,81 @@ fn bind_unix_mode(path: &std::path::Path, mode: u32) -> std::io::Result { + io: S, + _permit: OwnedSemaphorePermit, +} + +impl AsyncRead for HeldConn { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_read(cx, buf) + } +} + +impl AsyncWrite for HeldConn { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_shutdown(cx) + } +} + +/// Drops a new socket when [`RPC_MAX_CONNECTIONS`] are already open. +struct AcceptCap { + listener: L, + sem: Arc, +} + +impl axum::serve::Listener for AcceptCap +where + L: axum::serve::Listener, + L::Io: AsyncRead + AsyncWrite + Unpin, + L::Addr: std::fmt::Debug, +{ + type Io = HeldConn; + type Addr = L::Addr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let (io, addr) = axum::serve::Listener::accept(&mut self.listener).await; + match self.sem.clone().try_acquire_owned() { + Ok(permit) => { + return ( + HeldConn { + io, + _permit: permit, + }, + addr, + ) + } + Err(_) => { + info!("rpc: reject {addr:?} (at max connections={RPC_MAX_CONNECTIONS})"); + drop(io); + } + } + } + } + + fn local_addr(&self) -> std::io::Result { + self.listener.local_addr() + } +} + /// RPC listen configuration. #[derive(Clone, Debug)] pub struct RpcConfig { @@ -167,6 +244,7 @@ pub async fn run_rpc( let work_queue = Arc::new(tokio::sync::Semaphore::new(n)); let rest_queue = Arc::new(tokio::sync::Semaphore::new(DEFAULT_RPC_WORK_QUEUE)); let shutdown = Arc::new(AtomicBool::new(false)); + let accept_sem = Arc::new(Semaphore::new(RPC_MAX_CONNECTIONS)); let mut tasks = Vec::new(); let mut local_addr = None; @@ -189,15 +267,22 @@ pub async fn run_rpc( }; let app = rpc_app(state); let shutdown_w = Arc::clone(&shutdown); + let accept_sem = Arc::clone(&accept_sem); tasks.push(tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(async move { - while !shutdown_w.load(Ordering::SeqCst) { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - }) - .await - .ok(); + axum::serve( + AcceptCap { + listener, + sem: accept_sem, + }, + app, + ) + .with_graceful_shutdown(async move { + while !shutdown_w.load(Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }) + .await + .ok(); })); info!( "rpc: HTTP JSON-RPC on {bound} (bearer token {})", @@ -225,15 +310,22 @@ pub async fn run_rpc( }; let app = rpc_app(state); let shutdown_w = Arc::clone(&shutdown); + let accept_sem = Arc::clone(&accept_sem); tasks.push(tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(async move { - while !shutdown_w.load(Ordering::SeqCst) { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - }) - .await - .ok(); + axum::serve( + AcceptCap { + listener, + sem: accept_sem, + }, + app, + ) + .with_graceful_shutdown(async move { + while !shutdown_w.load(Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }) + .await + .ok(); })); info!("rpc: unix JSON-RPC on {}", sock.display()); Some(sock.clone()) @@ -275,11 +367,6 @@ fn rpc_app(state: AppState) -> Router { .route("/rest/{*path}", get(rest_entry).post(rest_entry)) .layer(DefaultBodyLimit::max(RPC_MAX_HTTP_BODY)) .layer(from_fn_with_state(state.clone(), reject_unauthorized)) - .layer(TimeoutLayer::with_status_code( - StatusCode::REQUEST_TIMEOUT, - std::time::Duration::from_millis(crate::methods::RPC_WAIT_TIMEOUT_MS), - )) - .layer(ConcurrencyLimitLayer::new(RPC_MAX_CONNECTIONS)) .with_state(state) } @@ -1304,6 +1391,114 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The two-minute cap is inside the handler. The response is the JSON-RPC + /// tip, not an empty 408. + #[tokio::test] + async fn long_poll_at_the_cap_returns_a_json_body() { + let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-wait-cap").expect("dir"); + let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); + let hub = rbitcoin_net::ChainHub::new( + q, + rbitcoin_consensus::ChainParams::regtest(), + rbitcoin_consensus::Milestone::NONE, + ); + hub.ensure_genesis().unwrap(); + let query = Arc::clone(&hub.query); + let cfg = RpcConfig { + listen: Some("127.0.0.1:0".parse().unwrap()), + socket_path: None, + socket_shared: false, + datadir: dir.path().to_path_buf(), + network: Network::Regtest, + token_path: None, + cookie_path: None, + subversion: None, + work_queue: None, + rest: false, + alert_notify: None, + }; + let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) + .await + .unwrap(); + let missing = "00".repeat(32); + let req = serde_json::json!({ + "jsonrpc": "1.0", + "id": 1, + "method": "waitforblock", + "params": [missing, crate::methods::RPC_WAIT_TIMEOUT_MS] + }); + let (st, body) = tokio::time::timeout( + std::time::Duration::from_secs(150), + post_raw(tcp_addr(&handle), &handle.auth, req.to_string().as_bytes()), + ) + .await + .expect("capped long-poll must return"); + assert_eq!(st, 200, "{body:?}"); + let body = body.expect("json-rpc body"); + assert!( + body.get("result").is_some_and(|r| !r.is_null()), + "cap returns the tip, got {body}" + ); + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn extra_rpc_accept_is_dropped() { + use tokio::io::AsyncReadExt; + let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-accept-cap").expect("dir"); + let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); + let hub = rbitcoin_net::ChainHub::new( + q, + rbitcoin_consensus::ChainParams::regtest(), + rbitcoin_consensus::Milestone::NONE, + ); + hub.ensure_genesis().unwrap(); + let query = Arc::clone(&hub.query); + let cfg = RpcConfig { + listen: Some("127.0.0.1:0".parse().unwrap()), + socket_path: None, + socket_shared: false, + datadir: dir.path().to_path_buf(), + network: Network::Regtest, + token_path: None, + cookie_path: None, + subversion: None, + work_queue: None, + rest: false, + alert_notify: None, + }; + let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let addr = tcp_addr(&handle); + let mut held = Vec::with_capacity(RPC_MAX_CONNECTIONS); + for _ in 0..RPC_MAX_CONNECTIONS { + held.push(tokio::net::TcpStream::connect(addr).await.unwrap()); + } + let mut extra = tokio::net::TcpStream::connect(addr).await.unwrap(); + let mut buf = [0u8; 8]; + let read = + tokio::time::timeout(std::time::Duration::from_secs(2), extra.read(&mut buf)).await; + assert!( + matches!(read, Ok(Ok(0)) | Ok(Err(_))), + "an accept past the cap must be dropped, got {read:?}" + ); + drop(held); + drop(extra); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let (st, body) = post_raw( + addr, + &handle.auth, + br#"{"jsonrpc":"1.0","id":1,"method":"getblockcount"}"#, + ) + .await; + assert_eq!(st, 200, "{body:?}"); + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn waitfor_does_not_hold_the_blocking_pool() { let rt = tokio::runtime::Builder::new_multi_thread() From cb18c33535ce9cc4461707fbb68c29f55db547c1 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:17:44 -0700 Subject: [PATCH 09/11] test: opt the esplora broadcast journey into REST The journey pins /rest/ routes and expects HTTP 200. REST stays off unless the node sets it, so the pins were 404. --- crates/rbitcoin-test/tests/cross_surface.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index 6c4272b83..0bc543e6f 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -1291,6 +1291,7 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { cfg.listen.electrum = Some(electrum_addr); cfg.listen.esplora = Some(rbitcoin_esplora::EsploraListen::Tcp(esplora_addr)); cfg.rpc.listen = Some(rpc_addr); + cfg.rpc.rest = true; cfg.listen.health = Some(health_addr); cfg.metrics = true; // mempool's CORE_RPC.SOCKET_PATH reaches the node from another user. From 5d4927b03a385a8a9d3c80f86704dad04d88072d Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:54:27 -0700 Subject: [PATCH 10/11] nix: bind the --rest module eval before reading ExecStart Attribute selection binds tighter than application, so an inline nixosSystem { }.config never evaluated and qc's module check failed. --- nix/tests/nixos-module-eval.nix | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index c8341ca6c..c930ac0e3 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -168,6 +168,23 @@ let rbitcoinScrape = builtins.head ( builtins.filter (j: j.job_name == "rbitcoin") prometheus.config.services.prometheus.scrapeConfigs ); + # Bind the eval. `.` binds tighter than application, so + # `nixosSystem { ... }.config` reads `config` off the attrset argument. + restOnSystem = nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.rest = true; + }; + } + ]; + }; + restOnExec = restOnSystem.config.systemd.services.rbitcoin.serviceConfig.ExecStart; failedAssertions = sys: map (a: a.message) (builtins.filter (a: !a.assertion) sys.config.assertions); in @@ -216,23 +233,7 @@ assert builtins.match ".*--network regtest.*" execStart != null; assert builtins.match ".*--listen 127.0.0.1:18444.*" execStart != null; assert builtins.match ".*--rpc-listen 127.0.0.1:18443.*" execStart != null; assert builtins.match ".*--rest.*" execStart == null; -assert builtins.match ".*--rest.*" ( - nixpkgs.lib.nixosSystem { - inherit (pkgs.stdenv.hostPlatform) system; - modules = [ - module - { - services.rbitcoin = { - enable = true; - package = fakePackage; - rpc.enable = true; - rpc.rest = true; - }; - } - ]; - } - .config.systemd.services.rbitcoin.serviceConfig.ExecStart -) != null; +assert builtins.match ".*--rest.*" restOnExec != null; assert builtins.match ".*--rpc-socket /run/rbitcoin/rpc.sock.*" execStart != null; assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" execStart != null; assert builtins.elem "/run/rbitcoin" service.serviceConfig.ReadWritePaths; From b56e005e95fe9b97eb57877c8b6c41c608301b98 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:03:18 -0700 Subject: [PATCH 11/11] log: truncate API params before copying a body with no secret Redaction still covers an extended key or scan secret that overlaps the logged prefix. A large submitblock body no longer allocates a full copy on the work-queue thread. --- changelog.d/api-log-prefix.md | 6 ++ crates/rbitcoin-log/src/api_log.rs | 111 ++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 changelog.d/api-log-prefix.md diff --git a/changelog.d/api-log-prefix.md b/changelog.d/api-log-prefix.md new file mode 100644 index 000000000..3ba04be1a --- /dev/null +++ b/changelog.d/api-log-prefix.md @@ -0,0 +1,6 @@ +Changed + +- **The API log truncates a large params body before copying it.** A + secret that overlaps the logged prefix is still redacted. A `submitblock` + body with no secret in that prefix is no longer copied in full on the + work-queue thread. diff --git a/crates/rbitcoin-log/src/api_log.rs b/crates/rbitcoin-log/src/api_log.rs index 93b9cda29..2fc9f76c9 100644 --- a/crates/rbitcoin-log/src/api_log.rs +++ b/crates/rbitcoin-log/src/api_log.rs @@ -54,8 +54,10 @@ fn compact_params(params: &str) -> String { /// Record one Electrum / Esplora / RPC call. /// -/// Extended private keys and silent-payment scan secrets are stripped here, -/// then the params blob is compacted. `err` is `None` on success. +/// Extended private keys and silent-payment scan secrets that overlap the +/// logged prefix are stripped here, then the params blob is compacted. +/// A large body with no secret in that prefix is truncated without copying +/// the tail. `err` is `None` on success. pub fn api_call( surface: &str, peer: &str, @@ -64,7 +66,7 @@ pub fn api_call( wall_ms: u64, err: Option<&str>, ) { - let params = compact_params(&redact_secrets(method, params)); + let params = logged_params(method, params); match err { None => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} ok"), Some(e) => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} err={e}"), @@ -91,13 +93,64 @@ pub fn api_call( let _ = file.flush(); } -fn redact_secrets(method: &str, params: &str) -> String { - let out = redact_ext_privkeys(params); +fn logged_params(method: &str, params: &str) -> String { + let cap = char_floor(params, PARAMS_MAX); + let Some(end) = secret_cover_end(method, params, cap) else { + return compact_params(params); + }; + let mut redacted = redact_ext_privkeys(¶ms[..end]); if method.contains("silentpayment") { - redact_quoted_hex64(&out) - } else { - out + redacted = redact_quoted_hex64(&redacted); } + compact_params(&redacted) +} + +fn char_floor(s: &str, max: usize) -> usize { + let mut end = max.min(s.len()); + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + end +} + +/// Exclusive end of the slice to redact, covering each secret that starts +/// inside the logged prefix plus `PARAMS_MAX` bytes after it. `None` when +/// that prefix has no secret, so the caller truncates without a full copy. +fn secret_cover_end(method: &str, params: &str, cap: usize) -> Option { + let b = params.as_bytes(); + let scan_hex = method.contains("silentpayment"); + let mut cover = None; + let mut i = 0; + while i < cap && i < b.len() { + if i + 4 <= b.len() { + let tag = &b[i..i + 4]; + if matches!(tag, b"xprv" | b"tprv" | b"yprv" | b"zprv") { + let mut j = i + 4; + while j < b.len() && is_base58(b[j]) { + j += 1; + } + if j > i + 4 { + let need = j.saturating_add(PARAMS_MAX).min(b.len()); + cover = Some(cover.unwrap_or(0).max(need)); + i = j; + continue; + } + } + } + if scan_hex + && b[i] == b'"' + && i + 66 <= b.len() + && b[i + 65] == b'"' + && b[i + 1..i + 65].iter().copied().all(is_hex) + { + let need = (i + 66).saturating_add(PARAMS_MAX).min(b.len()); + cover = Some(cover.unwrap_or(0).max(need)); + i += 66; + continue; + } + i += 1; + } + cover } fn is_base58(b: u8) -> bool { @@ -308,4 +361,46 @@ mod tests { assert!(!all.contains(&tprv), "{all}"); assert!(all.contains(""), "{all}"); } + + #[test] + fn logged_prefix_redacts_a_straddling_secret_and_drops_one_past_the_cap() { + let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let key = format!("xprv{}", "1".repeat(40)); + let hex_secret = "ab".repeat(32); + let straddle = format!("{}{key}{}", "a".repeat(PARAMS_MAX - 10), "b".repeat(40)); + let far = format!("{}{key}", "c".repeat(PARAMS_MAX + 80)); + let hex_params = format!("{}\"{hex_secret}\"tail", "d".repeat(PARAMS_MAX - 8)); + crate::capture_logs(true); + api_call("rpc", "-", "submitblock", &straddle, 1, None); + api_call("rpc", "-", "submitblock", &far, 1, None); + api_call( + "electrum", + "127.0.0.1:1", + "blockchain.silentpayments.unsubscribe", + &hex_params, + 1, + None, + ); + let logs = crate::take_logs(); + crate::capture_logs(false); + let trace = logs + .iter() + .map(|(_, msg)| msg.clone()) + .collect::>() + .join("\n"); + assert!(!trace.contains(&key), "{trace}"); + assert!(!trace.contains(&hex_secret), "{trace}"); + assert!(trace.contains(""), "{trace}"); + let far_mark = "c".repeat(40); + let far_line = logs + .iter() + .map(|(_, msg)| msg.as_str()) + .find(|msg| msg.contains(&far_mark)) + .expect("far submitblock line"); + assert!( + !far_line.contains(""), + "a secret past the cap is not part of the logged prefix: {far_line}" + ); + assert!(!far_line.contains("xprv"), "{far_line}"); + } }