From afaec1cb75417fb4d708b9af70848888ea2f5e5f Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Wed, 1 Jul 2026 18:13:04 -0300 Subject: [PATCH 1/2] feat(l1): opt-in for plaintext RPC to trusted private hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider rejected any non-HTTPS RPC whose host wasn't literal loopback, which broke reaching a node over a private container network (Docker/K8s service name, host.docker.internal, private-VPC IP) — e.g. `http://anvil:8545`. Plaintext there is normal and safe; anvil doesn't speak TLS at all. Add `--allow-insecure-rpc` / `CARTESI_SEQUENCER_ALLOW_INSECURE_RPC` (default false) on setup/run/flush-mempool. The default stays secure — remote plaintext is refused — but an operator can explicitly opt in for a trusted private network. The opt-in is loud (warn!) and auditable (shows up in args/env), and is threaded through every provider constructor so no path can silently downgrade. Also harden the loopback check: classify the parsed `url::Host` with `Ipv4Addr/Ipv6Addr::is_loopback()` instead of matching literal strings. This drops the `[::1]` bracket hack and fixes a latent bug where the whole 127.0.0.0/8 block except 127.0.0.1 was wrongly rejected. --- Cargo.lock | 1 + README.md | 2 + sequencer/Cargo.toml | 1 + sequencer/src/l1/provider.rs | 133 +++++++++++++++++++++------ sequencer/src/l1/reader.rs | 27 ++++-- sequencer/src/l1/submitter/poster.rs | 4 +- sequencer/src/recovery/flusher.rs | 2 + sequencer/src/recovery/mod.rs | 1 + sequencer/src/runtime/config.rs | 38 ++++++++ sequencer/src/runtime/flush.rs | 1 + sequencer/src/runtime/mod.rs | 13 ++- sequencer/src/runtime/setup.rs | 13 ++- sequencer/src/runtime/workers.rs | 8 +- 13 files changed, 200 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3539857..7678cc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3890,6 +3890,7 @@ dependencies = [ "toml", "tower-http", "tracing", + "url", ] [[package]] diff --git a/README.md b/README.md index f00aff4..46aac50 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ nonce on demand (keyed operator tool). Optional: `CARTESI_SEQUENCER_HTTP_ADDR` (default `127.0.0.1:3000`, `run`), `CARTESI_SEQUENCER_DATA_DIR` (default `sequencer-data` — SQLite file is `sequencer.db` inside; created if missing), `CARTESI_SEQUENCER_PREEMPTIVE_MARGIN_BLOCKS` (default `300`), `CARTESI_SEQUENCER_SECONDS_PER_BLOCK` (default `12`), `CARTESI_SEQUENCER_L1_READ_STALE_AFTER_BLOCKS` (default `600`), `CARTESI_SEQUENCER_LONG_BLOCK_RANGE_ERROR_CODES` (default `-32005,-32600,-32602,-32616`), `CARTESI_SEQUENCER_AUTH_PRIVATE_KEY_FILE` (alternative to `CARTESI_SEQUENCER_AUTH_PRIVATE_KEY`; first line of the file is the key), `CARTESI_SEQUENCER_BATCH_SUBMITTER_IDLE_POLL_INTERVAL_MS`, `CARTESI_SEQUENCER_BATCH_SUBMITTER_CONFIRMATION_DEPTH`. +By default the blockchain endpoint must be `https://` unless its host is loopback (`localhost`, `127.0.0.0/8`, `::1`) — a guard against accidentally sending L1 traffic to a public RPC in the clear. Set `CARTESI_SEQUENCER_ALLOW_INSECURE_RPC=true` (or `--allow-insecure-rpc`) to permit plaintext `http://` to a non-loopback host on a **trusted private network** — e.g. a Docker Compose / Kubernetes service name (`http://anvil:8545`), `host.docker.internal`, or a private-VPC IP. It applies to `setup`, `run`, and `flush-mempool`. + Process exit codes follow the R4 orchestrator contract: `0` clean shutdown, `10` restart (expect a recovery boot), `20` transient refusal (retry with backoff), `30` terminal (operator required — e.g. setup not complete, identity mismatch, canonical divergence), `1`/`101` unclassified/panic. Fixed protocol identity (EIP-712): diff --git a/sequencer/Cargo.toml b/sequencer/Cargo.toml index 3ae2f26..be99c5b 100644 --- a/sequencer/Cargo.toml +++ b/sequencer/Cargo.toml @@ -26,6 +26,7 @@ alloy-sol-types = "1.4.1" alloy = { version = "1.0", features = ["contract", "network", "reqwest", "rpc-types", "sol-types", "node-bindings", "signer-local", "signers"] } alloy-network-primitives = "1.7" alloy-transport = "1.0" +url = "2.5" thiserror = "1" ssz = { package = "ethereum_ssz", version = "0.10" } ssz_derive = { package = "ethereum_ssz_derive", version = "0.10" } diff --git a/sequencer/src/l1/provider.rs b/sequencer/src/l1/provider.rs index 7c06f60..75166ee 100644 --- a/sequencer/src/l1/provider.rs +++ b/sequencer/src/l1/provider.rs @@ -11,6 +11,7 @@ use alloy::{ transports::http::{Http, reqwest, reqwest::Url}, }; use alloy_transport::layers::RetryBackoffLayer; +use url::Host; // Public Ethereum providers (Infura, Alchemy) commonly take 30–60s on heavy // `eth_getLogs` queries under load. The partition-retry helper in @@ -23,17 +24,30 @@ const MAX_RATE_LIMIT_RETRIES: u32 = 5; const INITIAL_BACKOFF_MS: u64 = 200; const COMPUTE_UNITS_PER_SEC: u64 = 500; -fn create_client(url: &str) -> Result { +fn create_client(url: &str, allow_insecure: bool) -> Result { let url = Url::parse(url).map_err(|e| format!("invalid RPC URL: {e}"))?; - // Reject non-HTTPS for remote hosts to prevent accidental plaintext RPC. - // `url::Url::host_str` returns bracket-wrapped IPv6 literals (e.g. "[::1]"). - if url.scheme() != "https" && !is_loopback_host(url.host_str().unwrap_or("")) { + // Reject non-HTTPS for remote hosts to prevent accidental plaintext RPC to a + // public endpoint (eavesdropping / MITM of signed L1 traffic). Loopback is + // always allowed; any other host requires `allow_insecure` — the explicit + // operator opt-in for trusted private networks (a Docker/K8s service name, + // `host.docker.internal`, a private-VPC IP) where anvil-style plaintext is + // normal and safe. The opt-out is loud, never silent. + let remote_plaintext = url.scheme() != "https" && !is_loopback_host(url.host()); + if remote_plaintext && !allow_insecure { return Err(format!( - "remote RPC must use https, got {}://", + "remote RPC must use https, got {}:// (set \ + CARTESI_SEQUENCER_ALLOW_INSECURE_RPC=true / --allow-insecure-rpc if \ + this host is on a trusted private network, e.g. a Docker/K8s service)", url.scheme() )); } + if remote_plaintext { + tracing::warn!( + url = %url, + "insecure plaintext RPC to a non-loopback host — explicitly allowed via allow-insecure-rpc" + ); + } let http_client = reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) @@ -54,24 +68,39 @@ fn create_client(url: &str) -> Result { .transport(transport, is_local)) } -/// Check whether a URL host string refers to a loopback address. +/// Whether a parsed URL host refers to a loopback address. /// -/// `url::Url::host_str` wraps IPv6 literals in brackets (e.g. `[::1]`), which -/// this helper normalizes alongside the IPv4 and DNS forms. -fn is_loopback_host(host: &str) -> bool { - matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") +/// Uses the already-parsed [`url::Host`] rather than the raw host string, so IP +/// literals are classified by [`std::net::Ipv4Addr::is_loopback`] / +/// [`std::net::Ipv6Addr::is_loopback`] (covering the whole `127.0.0.0/8` block +/// and canonical `::1`, with no bracket-stripping). `localhost` is the reserved +/// loopback name (RFC 6761) — a convention no library computes offline, so it +/// stays the one explicit string. +fn is_loopback_host(host: Option>) -> bool { + match host { + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + Some(Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"), + None => false, + } } -/// Create a read-only provider with retry and timeout. -pub fn create_provider(url: &str) -> Result { - let client = create_client(url)?; +/// Create a read-only provider with retry and timeout. `allow_insecure` opts +/// into plaintext RPC against a non-loopback host — see [`create_client`]. +pub fn create_provider(url: &str, allow_insecure: bool) -> Result { + let client = create_client(url, allow_insecure)?; let provider = ProviderBuilder::new().connect_client(client); Ok(provider.erased()) } -/// Create a provider with a wallet signer, retry, and timeout. -pub fn create_signer_provider(url: &str, private_key: &str) -> Result { - let client = create_client(url)?; +/// Create a provider with a wallet signer, retry, and timeout. `allow_insecure` +/// opts into plaintext RPC against a non-loopback host — see [`create_client`]. +pub fn create_signer_provider( + url: &str, + private_key: &str, + allow_insecure: bool, +) -> Result { + let client = create_client(url, allow_insecure)?; let signer = PrivateKeySigner::from_str(private_key).map_err(|_| "invalid private key".to_string())?; let provider = ProviderBuilder::new().wallet(signer).connect_client(client); @@ -107,9 +136,10 @@ pub async fn create_verified_signer_provider( url: &str, private_key: &str, expected_chain_id: u64, + allow_insecure: bool, ) -> Result { - let provider = - create_signer_provider(url, private_key).map_err(VerifiedSignerProviderError::Create)?; + let provider = create_signer_provider(url, private_key, allow_insecure) + .map_err(VerifiedSignerProviderError::Create)?; let rpc_chain_id = provider .get_chain_id() .await @@ -131,7 +161,7 @@ mod tests { #[test] fn create_client_rejects_http_for_remote_host() { - let err = create_client("http://mainnet.infura.io/v3/abc123") + let err = create_client("http://mainnet.infura.io/v3/abc123", false) .expect_err("http:// for remote host must be rejected"); assert!( err.contains("https"), @@ -141,22 +171,64 @@ mod tests { #[test] fn create_client_accepts_http_for_127_0_0_1() { - create_client("http://127.0.0.1:8545").expect("loopback http:// must be accepted"); + create_client("http://127.0.0.1:8545", false).expect("loopback http:// must be accepted"); } #[test] fn create_client_accepts_http_for_localhost() { - create_client("http://localhost:8545").expect("localhost http:// must be accepted"); + create_client("http://localhost:8545", false).expect("localhost http:// must be accepted"); } #[test] fn create_client_accepts_http_for_ipv6_loopback() { - create_client("http://[::1]:8545").expect("IPv6 loopback http:// must be accepted"); + create_client("http://[::1]:8545", false).expect("IPv6 loopback http:// must be accepted"); + } + + #[test] + fn create_client_accepts_http_for_127_0_0_0_8_block() { + // The whole 127.0.0.0/8 range is loopback — `is_loopback()` covers it, + // whereas the old literal `== "127.0.0.1"` match rejected these. + create_client("http://127.0.0.2:8545", false).expect("127.0.0.2 is loopback"); + create_client("http://127.1.2.3:8545", false).expect("127.1.2.3 is loopback"); } #[test] fn create_client_accepts_https_for_remote_host() { - create_client("https://mainnet.infura.io/v3/abc123").expect("https:// must be accepted"); + create_client("https://mainnet.infura.io/v3/abc123", false) + .expect("https:// must be accepted"); + } + + // ── Option A: explicit opt-in for plaintext to a trusted private host ── + + #[test] + fn create_client_rejects_http_for_docker_service_name_by_default() { + // A Docker Compose / K8s service name is not loopback; default-secure + // rejects it, and the error points the operator at the opt-in. + let err = create_client("http://anvil:8545", false) + .expect_err("http:// to a bare service name must be rejected by default"); + assert!( + err.contains("https") && err.contains("ALLOW_INSECURE_RPC"), + "error should explain https requirement and the opt-in, got: {err}" + ); + } + + #[test] + fn create_client_allows_insecure_rpc_when_opted_in() { + // The explicit operator opt-in permits plaintext to a non-loopback host + // (Docker service name, private IP, host.docker.internal). + create_client("http://anvil:8545", true) + .expect("service name http:// allowed when opted in"); + create_client("http://10.0.1.5:8545", true) + .expect("private IP http:// allowed when opted in"); + create_client("http://host.docker.internal:8545", true) + .expect("host.docker.internal http:// allowed when opted in"); + } + + #[test] + fn create_client_still_allows_loopback_without_the_opt_in() { + // The opt-in is not required for loopback — that stays a zero-config + // safe default even with allow_insecure = false. + create_client("http://127.0.0.1:8545", false).expect("loopback needs no opt-in"); } // ── H3 regression: private-key parse error must not echo bytes ─ @@ -169,7 +241,7 @@ mod tests { // match — so a future change that re-adds interpolation is caught. let bad_key = "0xZZZZ_zzzz_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff"; - let err = create_signer_provider("http://127.0.0.1:8545", bad_key) + let err = create_signer_provider("http://127.0.0.1:8545", bad_key, false) .expect_err("malformed hex key must be rejected"); assert_eq!( err, "invalid private key", @@ -187,7 +259,7 @@ mod tests { // Odd-length hex would trigger a different error variant. Same // invariant: fixed error message, no key bytes leaked. let bad_key = "0xabc"; - let err = create_signer_provider("http://127.0.0.1:8545", bad_key) + let err = create_signer_provider("http://127.0.0.1:8545", bad_key, false) .expect_err("odd-length hex key must be rejected"); assert_eq!(err, "invalid private key"); } @@ -195,7 +267,7 @@ mod tests { #[test] fn create_signer_provider_accepts_valid_key() { let good_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - create_signer_provider("http://127.0.0.1:8545", good_key) + create_signer_provider("http://127.0.0.1:8545", good_key, false) .expect("valid key must be accepted"); } @@ -226,15 +298,16 @@ mod tests { let chain_id = anvil.chain_id(); // Matching chain id → a usable signing provider. - create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id) + create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id, false) .await .expect("matching chain id yields a verified signer provider"); // Wrong pinned chain id → terminal ChainIdMismatch, before any signing, // surfacing the served id vs the pinned one. - let err = create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id + 1) - .await - .expect_err("a mismatched pinned chain id must be rejected before signing"); + let err = + create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id + 1, false) + .await + .expect_err("a mismatched pinned chain id must be rejected before signing"); assert!( matches!( err, diff --git a/sequencer/src/l1/reader.rs b/sequencer/src/l1/reader.rs index 7a9384b..2915ce6 100644 --- a/sequencer/src/l1/reader.rs +++ b/sequencer/src/l1/reader.rs @@ -26,6 +26,11 @@ use sequencer_core::protocol::ProtocolTiming; #[derive(Debug, Clone)] pub struct InputReaderConfig { pub rpc_url: String, + /// Opt into plaintext (`http://`) RPC against a non-loopback host — a + /// trusted private network (Docker/K8s service, private-VPC IP). Off by + /// default: the provider layer refuses remote plaintext otherwise. See + /// [`crate::l1::provider`]. + pub allow_insecure_rpc: bool, pub app_address: Address, pub poll_interval: Duration, /// Error codes that trigger `get_logs` retries with a shorter block range. @@ -86,8 +91,9 @@ impl InputReader { batch_submitter: Address, timing: ProtocolTiming, ) -> Result { - let provider = crate::l1::provider::create_provider(&config.rpc_url) - .map_err(InputReaderError::Bootstrap)?; + let provider = + crate::l1::provider::create_provider(&config.rpc_url, config.allow_insecure_rpc) + .map_err(InputReaderError::Bootstrap)?; let application = Application::new(config.app_address, &provider); let data_availability = application .getDataAvailability() @@ -170,8 +176,11 @@ impl InputReader { } pub async fn sync_to_current_safe_head(&mut self) -> Result<(), InputReaderError> { - let provider = crate::l1::provider::create_provider(&self.config.rpc_url) - .map_err(InputReaderError::Bootstrap)?; + let provider = crate::l1::provider::create_provider( + &self.config.rpc_url, + self.config.allow_insecure_rpc, + ) + .map_err(InputReaderError::Bootstrap)?; self.advance_once(&provider).await } @@ -193,8 +202,11 @@ impl InputReader { /// errors propagate. Shutdown is handled by the outer `run_forever` /// select, so this loop has no shutdown concerns. async fn run_loop(mut self) -> Result<(), InputReaderError> { - let provider = crate::l1::provider::create_provider(&self.config.rpc_url) - .map_err(InputReaderError::Bootstrap)?; + let provider = crate::l1::provider::create_provider( + &self.config.rpc_url, + self.config.allow_insecure_rpc, + ) + .map_err(InputReaderError::Bootstrap)?; loop { match self.advance_once(&provider).await { Ok(()) => {} @@ -542,6 +554,7 @@ mod tests { InputReader::from_parts( InputReaderConfig { rpc_url, + allow_insecure_rpc: false, app_address: Address::ZERO, poll_interval, long_block_range_error_codes: Vec::new(), @@ -660,6 +673,7 @@ mod tests { let mut reader = InputReader::from_parts( InputReaderConfig { rpc_url: anvil.endpoint_url().to_string(), + allow_insecure_rpc: false, app_address: Address::ZERO, poll_interval: Duration::from_secs(1), long_block_range_error_codes: Vec::new(), @@ -744,6 +758,7 @@ mod tests { db_file.path().to_string_lossy().into_owned(), InputReaderConfig { rpc_url: "not-a-valid-url".to_string(), + allow_insecure_rpc: false, app_address: Address::ZERO, poll_interval: Duration::from_secs(1), long_block_range_error_codes: Vec::new(), diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index 9704afe..4995c11 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -456,7 +456,7 @@ mod tests { // Anvil account 0 — the submitter; its key signs the (never-sent) txs. let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key) + let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false) .expect("signer provider"); let config = BatchPosterConfig { @@ -513,7 +513,7 @@ mod tests { let anvil = Anvil::default().spawn(); let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key) + let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false) .expect("signer provider"); let wrong_chain_id = anvil.chain_id() + 1; diff --git a/sequencer/src/recovery/flusher.rs b/sequencer/src/recovery/flusher.rs index e2207af..e7de31f 100644 --- a/sequencer/src/recovery/flusher.rs +++ b/sequencer/src/recovery/flusher.rs @@ -500,6 +500,7 @@ mod tests { crate::l1::provider::create_signer_provider( anvil.endpoint_url().as_str(), &format!("0x{key_hex}"), + false, ) .expect("create signer provider") } @@ -750,6 +751,7 @@ mod tests { let proxied_provider = crate::l1::provider::create_signer_provider( proxy.endpoint().as_str(), &format!("0x{key_hex}"), + false, ) .expect("create signer provider through proxy"); diff --git a/sequencer/src/recovery/mod.rs b/sequencer/src/recovery/mod.rs index c7a4139..98b84f7 100644 --- a/sequencer/src/recovery/mod.rs +++ b/sequencer/src/recovery/mod.rs @@ -370,6 +370,7 @@ async fn run_flush_and_cascade( &l1_config.eth_rpc_url, &l1_config.batch_submitter_private_key, l1_config.chain_id, + l1_config.allow_insecure_rpc, ) .await .map_err(|e| match e { diff --git a/sequencer/src/runtime/config.rs b/sequencer/src/runtime/config.rs index 52df617..bc4cea7 100644 --- a/sequencer/src/runtime/config.rs +++ b/sequencer/src/runtime/config.rs @@ -39,6 +39,11 @@ pub struct L1Config { /// the preemptive-recovery flush) can re-confirm the RPC's chain id right /// before signing via [`crate::l1::provider::create_verified_signer_provider`]. pub chain_id: u64, + /// Opt into plaintext (`http://`) RPC against a non-loopback host — a + /// trusted private network (Docker/K8s service, private-VPC IP). Off by + /// default: the provider layer refuses remote plaintext otherwise. See + /// [`crate::l1::provider`]. + pub allow_insecure_rpc: bool, } /// Full path to the SQLite database file inside `data_dir`. @@ -201,6 +206,17 @@ pub struct SetupConfig { pub data_dir: String, #[arg(long, env = "CARTESI_SEQUENCER_BLOCKCHAIN_HTTP_ENDPOINT", value_parser = parse_non_empty_string)] pub eth_rpc_url: String, + /// Allow plaintext (`http://`) RPC to a non-loopback host — a trusted + /// private network (Docker/K8s service name, `host.docker.internal`, a + /// private-VPC IP) where anvil-style plaintext is normal. Off by default: + /// the provider refuses remote plaintext otherwise, guarding against + /// accidentally sending L1 traffic over the public internet in the clear. + #[arg( + long, + env = "CARTESI_SEQUENCER_ALLOW_INSECURE_RPC", + default_value_t = false + )] + pub allow_insecure_rpc: bool, /// Error codes that trigger `get_logs` retries with a shorter block range. #[arg(long, env = "CARTESI_SEQUENCER_LONG_BLOCK_RANGE_ERROR_CODES", value_delimiter = ',', default_values = crate::l1::partition::DEFAULT_LONG_BLOCK_RANGE_ERROR_CODES)] pub long_block_range_error_codes: Vec, @@ -316,6 +332,17 @@ pub struct RunConfig { pub data_dir: String, #[arg(long, env = "CARTESI_SEQUENCER_BLOCKCHAIN_HTTP_ENDPOINT", value_parser = parse_non_empty_string)] pub eth_rpc_url: String, + /// Allow plaintext (`http://`) RPC to a non-loopback host — a trusted + /// private network (Docker/K8s service name, `host.docker.internal`, a + /// private-VPC IP) where anvil-style plaintext is normal. Off by default: + /// the provider refuses remote plaintext otherwise, guarding against + /// accidentally sending L1 traffic over the public internet in the clear. + #[arg( + long, + env = "CARTESI_SEQUENCER_ALLOW_INSECURE_RPC", + default_value_t = false + )] + pub allow_insecure_rpc: bool, /// Error codes that trigger `get_logs` retries with a shorter block range. #[arg(long, env = "CARTESI_SEQUENCER_LONG_BLOCK_RANGE_ERROR_CODES", value_delimiter = ',', default_values = crate::l1::partition::DEFAULT_LONG_BLOCK_RANGE_ERROR_CODES)] pub long_block_range_error_codes: Vec, @@ -386,6 +413,17 @@ pub struct FlushConfig { pub data_dir: String, #[arg(long, env = "CARTESI_SEQUENCER_BLOCKCHAIN_HTTP_ENDPOINT", value_parser = parse_non_empty_string)] pub eth_rpc_url: String, + /// Allow plaintext (`http://`) RPC to a non-loopback host — a trusted + /// private network (Docker/K8s service name, `host.docker.internal`, a + /// private-VPC IP) where anvil-style plaintext is normal. Off by default: + /// the provider refuses remote plaintext otherwise, guarding against + /// accidentally sending L1 traffic over the public internet in the clear. + #[arg( + long, + env = "CARTESI_SEQUENCER_ALLOW_INSECURE_RPC", + default_value_t = false + )] + pub allow_insecure_rpc: bool, #[command(flatten)] key: KeyArgs, /// Assumed L1 block time in seconds; sets the flusher's confirmation / diff --git a/sequencer/src/runtime/flush.rs b/sequencer/src/runtime/flush.rs index 47c2a65..c5094aa 100644 --- a/sequencer/src/runtime/flush.rs +++ b/sequencer/src/runtime/flush.rs @@ -45,6 +45,7 @@ pub async fn flush_mempool(config: FlushConfig) -> Result<(), RunError> { &config.eth_rpc_url, &key, identity.chain_id, + config.allow_insecure_rpc, ) .await .map_err(|e| match e { diff --git a/sequencer/src/runtime/mod.rs b/sequencer/src/runtime/mod.rs index 2eb6f1c..5a2d8bb 100644 --- a/sequencer/src/runtime/mod.rs +++ b/sequencer/src/runtime/mod.rs @@ -80,7 +80,13 @@ where // chain id on its first successful contact (`InputReaderConfig::expected_chain_id`), // so a provider that reconnects on the wrong chain fails loud before // ingesting any address-filtered foreign logs. - match validate_rpc_chain_id(&config.eth_rpc_url, identity.chain_id).await { + match validate_rpc_chain_id( + &config.eth_rpc_url, + identity.chain_id, + config.allow_insecure_rpc, + ) + .await + { Ok(()) => {} Err(RunError::Bootstrap(BootstrapError::ChainIdRpc { message })) => { tracing::warn!( @@ -98,6 +104,7 @@ where batch_submitter_private_key: key, batch_submitter_address: identity.batch_submitter_address, chain_id: identity.chain_id, + allow_insecure_rpc: config.allow_insecure_rpc, }; // `run` never re-discovers identity from L1 — it builds the reader from @@ -105,6 +112,7 @@ where let mut input_reader = InputReader::from_parts( InputReaderConfig { rpc_url: config.eth_rpc_url.clone(), + allow_insecure_rpc: config.allow_insecure_rpc, app_address: identity.app_address, poll_interval: INPUT_READER_POLL_INTERVAL, long_block_range_error_codes: config.long_block_range_error_codes.clone(), @@ -200,9 +208,10 @@ pub(crate) fn load_setup_identity(db_path: &str) -> Result Result<(), RunError> { use alloy::providers::Provider; - let check_provider = crate::l1::provider::create_provider(eth_rpc_url) + let check_provider = crate::l1::provider::create_provider(eth_rpc_url, allow_insecure) .map_err(|e| RunError::Io(std::io::Error::other(e)))?; match check_provider.get_chain_id().await { Ok(rpc_chain_id) if rpc_chain_id != expected => { diff --git a/sequencer/src/runtime/setup.rs b/sequencer/src/runtime/setup.rs index 0b2fd2f..7c3830a 100644 --- a/sequencer/src/runtime/setup.rs +++ b/sequencer/src/runtime/setup.rs @@ -75,6 +75,7 @@ where // ── L1 discovery (required) ────────────────────────────── let input_reader_config = InputReaderConfig { rpc_url: config.eth_rpc_url.clone(), + allow_insecure_rpc: config.allow_insecure_rpc, app_address: config.app_address, poll_interval: super::INPUT_READER_POLL_INTERVAL, long_block_range_error_codes: config.long_block_range_error_codes.clone(), @@ -104,7 +105,12 @@ where } }; - validate_rpc_chain_id(&config.eth_rpc_url, config.chain_id).await?; + validate_rpc_chain_id( + &config.eth_rpc_url, + config.chain_id, + config.allow_insecure_rpc, + ) + .await?; // ── Pin identity ───────────────────────────────────────── // INVARIANT: identity is pinned (this step) BEFORE the initial sync @@ -199,6 +205,7 @@ where &config.eth_rpc_url, config.batch_submitter_address, synced_safe_block, + config.allow_insecure_rpc, ) .await?; run_detection_gate( @@ -439,6 +446,7 @@ async fn flush_wallet_nonce( &config.eth_rpc_url, &key, config.chain_id, + config.allow_insecure_rpc, ) .await .map_err(|e| match e { @@ -539,11 +547,12 @@ async fn read_submitter_nonce_views( eth_rpc_url: &str, batch_submitter: Address, safe_block: Option, + allow_insecure: bool, ) -> Result<(u64, u64), BootstrapError> { use alloy::providers::Provider; use alloy::rpc::types::BlockNumberOrTag; - let provider = crate::l1::provider::create_provider(eth_rpc_url) + let provider = crate::l1::provider::create_provider(eth_rpc_url, allow_insecure) .map_err(|message| BootstrapError::DetectionNonceRead { message })?; let pending = provider .get_transaction_count(batch_submitter) diff --git a/sequencer/src/runtime/workers.rs b/sequencer/src/runtime/workers.rs index 170d399..fe0320d 100644 --- a/sequencer/src/runtime/workers.rs +++ b/sequencer/src/runtime/workers.rs @@ -488,8 +488,12 @@ fn log_cleanup_result(component: &str, result: Result<(), WorkerExit>) { // keyed-write guard instead lives in `EthereumBatchPoster::submit_batches`, // which re-confirms the chain id immediately before every productive send. fn build_batch_submitter_provider(l1: &L1Config) -> Result { - crate::l1::provider::create_signer_provider(&l1.eth_rpc_url, &l1.batch_submitter_private_key) - .map_err(std::io::Error::other) + crate::l1::provider::create_signer_provider( + &l1.eth_rpc_url, + &l1.batch_submitter_private_key, + l1.allow_insecure_rpc, + ) + .map_err(std::io::Error::other) } /// Require the finalized snapshot the lane will `from_dump` against. `setup` From c3af3303fb036847856a766ef8f5bd0f0bcfefad Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 2 Jul 2026 10:35:13 -0300 Subject: [PATCH 2/2] docs(l1): note allow-insecure-rpc is per-invocation; add compose example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback: spell out that `CARTESI_SEQUENCER_ALLOW_INSECURE_RPC` is not pinned into the DB at `setup` — it must be set on every L1-dialing subcommand (`setup`, `run`, `flush-mempool`), and omitting it on `run` after a successful `setup` fails loud (`remote RPC must use https`) by design, not a bug. Add a Docker Compose snippet showing it in the shared environment. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 46aac50..065171f 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,15 @@ nonce on demand (keyed operator tool). Optional: `CARTESI_SEQUENCER_HTTP_ADDR` (default `127.0.0.1:3000`, `run`), `CARTESI_SEQUENCER_DATA_DIR` (default `sequencer-data` — SQLite file is `sequencer.db` inside; created if missing), `CARTESI_SEQUENCER_PREEMPTIVE_MARGIN_BLOCKS` (default `300`), `CARTESI_SEQUENCER_SECONDS_PER_BLOCK` (default `12`), `CARTESI_SEQUENCER_L1_READ_STALE_AFTER_BLOCKS` (default `600`), `CARTESI_SEQUENCER_LONG_BLOCK_RANGE_ERROR_CODES` (default `-32005,-32600,-32602,-32616`), `CARTESI_SEQUENCER_AUTH_PRIVATE_KEY_FILE` (alternative to `CARTESI_SEQUENCER_AUTH_PRIVATE_KEY`; first line of the file is the key), `CARTESI_SEQUENCER_BATCH_SUBMITTER_IDLE_POLL_INTERVAL_MS`, `CARTESI_SEQUENCER_BATCH_SUBMITTER_CONFIRMATION_DEPTH`. -By default the blockchain endpoint must be `https://` unless its host is loopback (`localhost`, `127.0.0.0/8`, `::1`) — a guard against accidentally sending L1 traffic to a public RPC in the clear. Set `CARTESI_SEQUENCER_ALLOW_INSECURE_RPC=true` (or `--allow-insecure-rpc`) to permit plaintext `http://` to a non-loopback host on a **trusted private network** — e.g. a Docker Compose / Kubernetes service name (`http://anvil:8545`), `host.docker.internal`, or a private-VPC IP. It applies to `setup`, `run`, and `flush-mempool`. +By default the blockchain endpoint must be `https://` unless its host is loopback (`localhost`, `127.0.0.0/8`, `::1`) — a guard against accidentally sending L1 traffic to a public RPC in the clear. Set `CARTESI_SEQUENCER_ALLOW_INSECURE_RPC=true` (or `--allow-insecure-rpc`) to permit plaintext `http://` to a non-loopback host on a **trusted private network** — e.g. a Docker Compose / Kubernetes service name (`http://anvil:8545`), `host.docker.internal`, or a private-VPC IP. + +The flag is **per-invocation, not pinned into the DB**: set it on every subcommand that dials L1 (`setup`, `run`, and `flush-mempool`). Setting it only on `setup` and then omitting it on `run` is refused at boot with `remote RPC must use https` — that is by design (each keyed/read path re-validates the endpoint), not a bug. In a container deployment, put it in the shared environment for all sequencer commands. Example (Docker Compose): + +```yaml +environment: + CARTESI_SEQUENCER_BLOCKCHAIN_HTTP_ENDPOINT: "http://anvil:8545" + CARTESI_SEQUENCER_ALLOW_INSECURE_RPC: "true" +``` Process exit codes follow the R4 orchestrator contract: `0` clean shutdown, `10` restart (expect a recovery boot), `20` transient refusal (retry with backoff), `30` terminal (operator required — e.g. setup not complete, identity mismatch, canonical divergence), `1`/`101` unclassified/panic.