From c030b2be836422bec9a6fb74d2ab85fd21d48f8b Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 14 May 2026 03:26:07 +0200 Subject: [PATCH 1/5] fix(audit): close 9 leak/DoS findings from 2026-05-13 security review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL - graphql: sanitize sqlx errors (block/blocks/transaction); log full error internally, return generic "internal server error" to client. - /metrics: split off main router; bind on 127.0.0.1:9080 by default (INDEXER_API_METRICS_BIND configurable). Public Caddy proxy can no longer expose Prometheus metrics. HIGH - etherscan: sanitize sqlx errors in txlist + getblocknobytime envelopes; log internally, return "database error". - readyz: static "down: pg error" / "down: cache error" on the wire; details to tracing. - coinblast worker: cap known_non_curves at 10_000 entries with arbitrary eviction over cap. Stops unbounded HashSet growth on chains with many spurious Buy/Sell-topic-shaped emitters. - etherscan txlist: reject startblock/endblock/page when non-default rather than silently ignoring (returned wrong-window results). MEDIUM - sqlx workspace dep: default-features = false + explicit feature list; drops `any` so resolver no longer pulls sqlx-mysql via macros-core during compilation. cargo audit RUSTSEC-2023-0071 (rsa Marvin) ignored with a justification — sqlx-macros-core still lists sqlx-mysql as an optional dep so it stays in Cargo.lock even though never compiled. - BlockHeight::as_u64 returns Option instead of panicking on negative. Sync uses BlockHeight(-1) as cursor sentinel; previously a future caller could panic-kill the worker. Provider/block_writer call sites updated to handle None explicitly. - bin/api.rs: documented the CorsLayer::permissive() assumption (no-auth public read API); flag for revisit if mandatory auth is added. --- .github/workflows/cargo-audit.yml | 3 +- Cargo.lock | 135 +++++++++++++++++++++++------ Cargo.toml | 6 +- bin/api.rs | 41 ++++++++- crates/api/src/graphql.rs | 16 +++- crates/api/src/lib.rs | 19 +++- crates/api/src/routes/etherscan.rs | 51 +++++++---- crates/api/src/routes/readyz.rs | 12 ++- crates/chain/src/provider.rs | 15 +++- crates/coinblast/src/worker.rs | 22 ++++- crates/domain/src/ids.rs | 23 +++-- crates/sync/src/block_writer.rs | 2 +- deny.toml | 9 ++ 13 files changed, 286 insertions(+), 68 deletions(-) diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index 165b8e9..29d3ea4 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -52,7 +52,8 @@ jobs: set +e cargo audit \ --ignore RUSTSEC-2024-0436 \ - --ignore RUSTSEC-2025-0134 + --ignore RUSTSEC-2025-0134 \ + --ignore RUSTSEC-2023-0071 status=$? set -e case "$status" in diff --git a/Cargo.lock b/Cargo.lock index adee17a..915e56c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,7 +386,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", - "itertools 0.13.0", + "itertools 0.14.0", "serde", "serde_json", "serde_with", @@ -520,7 +520,7 @@ checksum = "eed3ed3300a998f88639ed619fdbbd88bd82865e00c6a8ecb796c99eb12358f6" dependencies = [ "alloy-json-rpc", "alloy-transport", - "itertools 0.13.0", + "itertools 0.14.0", "opentelemetry", "opentelemetry-http", "reqwest 0.13.3", @@ -981,9 +981,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -991,9 +991,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -1543,9 +1543,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -2243,9 +2243,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -2986,6 +2986,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3088,9 +3097,9 @@ dependencies = [ [[package]] name = "keccak-asm" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa468878266ad91431012b3e5ef1bf9b170eab22883503a318d46857afa4579a" +checksum = "1766b89733097006f3a1388a02849865d6bc98c89273cb622e29fdd209922183" dependencies = [ "digest 0.10.7", "sha3-asm", @@ -3325,9 +3334,9 @@ dependencies = [ [[package]] name = "metrics-util" -version = "0.20.3" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e56997f084e57b045edf17c3ed8ba7f9f779c670df8206dfd1c736f4c02dc4a" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -3740,18 +3749,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -3967,7 +3976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -3988,7 +3997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4097,7 +4106,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4927,9 +4936,9 @@ dependencies = [ [[package]] name = "sha3-asm" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +checksum = "9f3f15d4e239ebe08413eed880e0f9b5af4b40ee0472543320efa91d488e96a7" dependencies = [ "cc", "cfg-if", @@ -6376,6 +6385,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -6409,13 +6427,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6428,6 +6463,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6440,6 +6481,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6452,12 +6499,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6470,6 +6529,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6482,6 +6547,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6494,6 +6565,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6506,6 +6583,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -6684,9 +6767,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 264cabd..ac1309d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,11 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } # Database -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "chrono", "bigdecimal", "uuid"] } +# default-features = false drops `any` (multi-backend abstraction) which we +# don't use; combined with explicit `postgres` it stops the resolver from +# pulling sqlx-mysql/sqlx-sqlite into Cargo.lock. Eliminates the rsa +# RUSTSEC-2023-0071 (Marvin) advisory chain (audit 2026-05-13). +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "chrono", "bigdecimal", "uuid", "json"] } # Chain types (alloy) alloy-primitives = "1" diff --git a/bin/api.rs b/bin/api.rs index f1eb80a..88b91c5 100644 --- a/bin/api.rs +++ b/bin/api.rs @@ -10,7 +10,7 @@ use std::time::Duration; use axum::http::StatusCode; use figment::Figment; use figment::providers::Env; -use indexer_api::{AppState, RouterConfig, make_router, observability}; +use indexer_api::{AppState, RouterConfig, make_router, metrics_router, observability}; use indexer_cache::{CacheClient, CacheConfig}; use indexer_db::{PoolConfig, connect}; use serde::Deserialize; @@ -43,6 +43,14 @@ struct ApiConfig { /// Per-IP burst. Default 50. #[serde(default = "default_burst")] indexer_api_rate_burst: u32, + /// Loopback address for the internal Prometheus `/metrics` listener. + /// Default 127.0.0.1:9080; set to empty to disable (audit 2026-05-13: + /// previously merged into the public router with no auth gating). + #[serde(default = "default_metrics_bind")] + indexer_api_metrics_bind: String, +} +fn default_metrics_bind() -> String { + "127.0.0.1:9080".to_string() } fn default_rate() -> u64 { 50 @@ -101,14 +109,43 @@ async fn main() -> anyhow::Result<()> { auth_token: cfg.indexer_api_bearer_token.clone(), rate_per_sec: cfg.indexer_api_rate_per_sec, rate_burst: cfg.indexer_api_rate_burst, - metrics_handle: Some(metrics_handle), + metrics_handle: None, }; + // Bind /metrics on a loopback-only listener so Caddy/edge proxy can + // never expose it. Operator scrapes via host-local Prometheus. + if !cfg.indexer_api_metrics_bind.is_empty() { + let bind = cfg.indexer_api_metrics_bind.clone(); + let mh = metrics_handle.clone(); + tokio::spawn(async move { + match tokio::net::TcpListener::bind(&bind).await { + Ok(listener) => { + tracing::info!(addr = %bind, "api: metrics listener up (loopback)"); + if let Err(e) = + axum::serve(listener, metrics_router(mh).into_make_service()).await + { + tracing::error!(error = %e, "api: metrics listener exited"); + } + } + Err(e) => { + tracing::error!(addr = %bind, error = %e, "api: metrics bind failed"); + } + } + }); + } else { + tracing::info!("api: INDEXER_API_METRICS_BIND empty; metrics endpoint disabled"); + } + let app = make_router(AppState { pool, cache }, router_cfg) .layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, Duration::from_secs(30), )) + // Permissive CORS is intentional: this is a no-auth public read API. + // Any browser origin should be able to fetch /blocks, /tx/, + // etc. for explorers + dashboards. If/when bearer auth becomes + // mandatory (not opt-in via INDEXER_API_BEARER_TOKEN), revisit and + // tighten to an allowlist (audit 2026-05-13). .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()); diff --git a/crates/api/src/graphql.rs b/crates/api/src/graphql.rs index c75d40a..4c69c83 100644 --- a/crates/api/src/graphql.rs +++ b/crates/api/src/graphql.rs @@ -206,7 +206,7 @@ impl QueryRoot { let state = ctx.data::()?.clone(); let row = blocks::get_by_height(&state.pool, BlockHeight(height)) .await - .map_err(|e| async_graphql::Error::new(e.to_string()))?; + .map_err(|e| sanitize_db_err("graphql.block", e))?; Ok(row.map(BlockGql::from)) } @@ -222,7 +222,7 @@ impl QueryRoot { let limit = first.map_or(25, |n| n.clamp(1, 100)) as i64; let rows = blocks::list_before(&state.pool, before.map(BlockHeight), limit) .await - .map_err(|e| async_graphql::Error::new(e.to_string()))?; + .map_err(|e| sanitize_db_err("graphql.blocks", e))?; Ok(rows.into_iter().map(BlockGql::from).collect()) } @@ -236,13 +236,13 @@ impl QueryRoot { let hash = hash.to_lowercase(); let tx = transactions::get_by_hash(&state.pool, &hash) .await - .map_err(|e| async_graphql::Error::new(e.to_string()))?; + .map_err(|e| sanitize_db_err("graphql.transaction", e))?; let Some(tx) = tx else { return Ok(None); }; let log_rows = logs::for_tx(&state.pool, &hash) .await - .map_err(|e| async_graphql::Error::new(e.to_string()))?; + .map_err(|e| sanitize_db_err("graphql.transaction.logs", e))?; Ok(Some(TransactionWithLogs { tx: tx.into(), logs: log_rows.into_iter().map(LogGql::from).collect(), @@ -250,6 +250,14 @@ impl QueryRoot { } } +/// Log full DB error internally, return generic message to client. Mirrors +/// `ApiError::user_message()` for the REST surface — raw sqlx strings can +/// leak schema/connection details (audit 2026-05-13). +fn sanitize_db_err(scope: &'static str, e: E) -> async_graphql::Error { + tracing::error!(scope = scope, error = %e, "graphql db failure"); + async_graphql::Error::new("internal server error") +} + /// Convenience — silence dead-code warnings on the Json import when the /// compile re-orders use blocks during axum router merges in tests. (Real /// Json import lives in routes/* modules.) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 5d61f01..8c8b5ec 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -116,11 +116,14 @@ pub fn make_router(state: AppState, cfg: RouterConfig) -> Router { .with_state(shared.clone()); let gql = graphql::router(schema).with_state(shared); - let mut app = Router::new().merge(rest).merge(gql); + let app = Router::new().merge(rest).merge(gql); - if let Some(handle) = cfg.metrics_handle { - app = app.merge(routes::metrics::router(handle)); - } + // /metrics is intentionally NOT merged here. It serves on a separate + // internal listener (see `metrics_router`) bound to 127.0.0.1 by the + // bin so the public Caddy proxy can never expose it (audit 2026-05-13). + // Drop the handle if the caller passed one — keeps RouterConfig + // backwards-compatible while the migration to dual-listener lands. + let _ = cfg.metrics_handle; app.layer(from_fn(observability::track_request)) .layer(GovernorLayer::new(governor)) @@ -131,6 +134,14 @@ pub fn make_router(state: AppState, cfg: RouterConfig) -> Router { .layer(from_fn(error::request_id_middleware)) } +/// Build the internal /metrics router. Bind this on 127.0.0.1:9080 (or +/// equivalent loopback-only address) — never expose through the public +/// proxy (audit 2026-05-13: previously merged into the public router with +/// no auth gating). +pub fn metrics_router(handle: PrometheusHandle) -> Router { + routes::metrics::router(handle) +} + /// Time-to-live tier hints exported for route handlers calling /// [`cached::get_or_load`]. Re-export from `indexer_cache` so handlers /// don't need a direct dep on the cache crate. diff --git a/crates/api/src/routes/etherscan.rs b/crates/api/src/routes/etherscan.rs index ec8c04a..7e3200c 100644 --- a/crates/api/src/routes/etherscan.rs +++ b/crates/api/src/routes/etherscan.rs @@ -28,13 +28,12 @@ struct EsQuery { module: Option, action: Option, address: Option, - /// Etherscan `startblock` — accepted for compat; we don't filter by it - /// yet (`for_address` doesn't take a height range). + /// Etherscan `startblock` — accepted for compat. Not yet filtered on + /// (`for_address` takes no height range); we surface a status=0 error + /// rather than silently returning the full set (audit 2026-05-13). #[serde(rename = "startblock")] - #[allow(dead_code)] start_block: Option, #[serde(rename = "endblock")] - #[allow(dead_code)] end_block: Option, page: Option, offset: Option, @@ -70,26 +69,41 @@ async fn txlist(state: &SharedState, q: EsQuery) -> EsEnvelope { return err("address parameter is required".into()); }; let addr = addr.to_lowercase(); - // Etherscan offset = page size, page = 1-indexed page number. We cap - // offset at 100 to align with the rest of the indexer's pagination caps. + // Reject params we don't actually honour. Silently ignoring them + // returned wrong-window results to clients that pin a height range + // (audit 2026-05-13). `page` is rejected too — without offset+page we + // can only ever return the first page, so accepting page>1 would lie. + let unsupported: Vec<&str> = [ + ("startblock", q.start_block.as_deref()), + ("endblock", q.end_block.as_deref()), + ("page", q.page.as_deref()), + ] + .iter() + .filter_map(|(name, v)| match v { + Some(s) if !s.is_empty() && *s != "0" && (*name != "page" || *s != "1") => Some(*name), + _ => None, + }) + .collect(); + if !unsupported.is_empty() { + return err(format!( + "param not supported: {}", + unsupported.join(",") + )); + } + // Etherscan offset = page size. We cap at 100 to align with the rest + // of the indexer's pagination caps. let offset = q .offset .as_deref() .and_then(|s| s.parse::().ok()) .unwrap_or(25) .clamp(1, 100); - let _page = q - .page - .as_deref() - .and_then(|s| s.parse::().ok()) - .unwrap_or(1) - .max(1); // Phase 5 helper returns newest-first; ?sort=asc would re-sort, but // Etherscan default is asc by default — we render newest-first as a // pragmatic default since most consumers walk newest-first anyway. let rows = match transactions::for_address(&state.pool, &addr, offset).await { Ok(r) => r, - Err(e) => return err(e.to_string()), + Err(e) => return db_err("etherscan.txlist", e), }; let result = rows .into_iter() @@ -140,10 +154,10 @@ async fn getblocknobytime(state: &SharedState, q: EsQuery) -> EsEnvelope { match row { Ok(Some(r)) => match r.try_get::("height") { Ok(h) => ok(Value::String(BlockHeight(h).0.to_string())), - Err(e) => err(e.to_string()), + Err(e) => db_err("etherscan.getblocknobytime.decode", e), }, Ok(None) => err(format!("no block at-or-{closest} timestamp {ts}")), - Err(e) => err(e.to_string()), + Err(e) => db_err("etherscan.getblocknobytime", e), } } @@ -171,6 +185,13 @@ fn err(msg: String) -> EsEnvelope { } } +/// Log full DB error internally, return a generic envelope so we don't +/// leak schema/connection details to callers (audit 2026-05-13). +fn db_err(scope: &'static str, e: E) -> EsEnvelope { + tracing::error!(scope = scope, error = %e, "etherscan db failure"); + err("database error".into()) +} + /// Router for the etherscan-compat `/api` entry point. pub fn router() -> Router { Router::new().route("/api", get(dispatch)) diff --git a/crates/api/src/routes/readyz.rs b/crates/api/src/routes/readyz.rs index 0c4df40..a52d9c7 100644 --- a/crates/api/src/routes/readyz.rs +++ b/crates/api/src/routes/readyz.rs @@ -24,6 +24,8 @@ use serde_json::{Value, json}; use std::time::Duration; async fn handler(State(state): State) -> (StatusCode, Json) { + // Static strings on the wire — raw sqlx/redis errors leak schema or + // connection details (audit 2026-05-13). Detail goes to tracing. let pg = match tokio::time::timeout( Duration::from_secs(1), sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.pool), @@ -31,7 +33,10 @@ async fn handler(State(state): State) -> (StatusCode, Json) .await { Ok(Ok(_)) => "ok".to_string(), - Ok(Err(e)) => format!("down: {e}"), + Ok(Err(e)) => { + tracing::error!(error = %e, "readyz: pg probe failed"); + "down: pg error".to_string() + } Err(_) => "down: timeout".to_string(), }; @@ -41,7 +46,10 @@ async fn handler(State(state): State) -> (StatusCode, Json) // Open / connection error / etc. → down. Cache miss is fine. Ok(_) => "ok".to_string(), Err(indexer_cache::CacheError::Open) => "down: circuit breaker open".to_string(), - Err(e) => format!("down: {e}"), + Err(e) => { + tracing::error!(error = %e, "readyz: cache probe failed"); + "down: cache error".to_string() + } }, }; diff --git a/crates/chain/src/provider.rs b/crates/chain/src/provider.rs index 82a9e65..1bb2cd7 100644 --- a/crates/chain/src/provider.rs +++ b/crates/chain/src/provider.rs @@ -58,7 +58,10 @@ impl ChainProvider { /// `eth_getBlockByNumber(h, full=true)`. Returns None if the node hasn't /// seen this height yet. pub async fn block_with_txs(&self, h: BlockHeight) -> ChainResult> { - let tag = BlockNumberOrTag::Number(h.as_u64()); + let n = h.as_u64().ok_or_else(|| { + ChainError::InvalidArgument(format!("block_with_txs: negative height {h:?}")) + })?; + let tag = BlockNumberOrTag::Number(n); self.inner .get_block_by_number(tag) .full() @@ -80,9 +83,13 @@ impl ChainProvider { "logs_in_range: to ({to:?}) < from ({from:?})" ))); } - let mut filter = Filter::new() - .from_block(from.as_u64()) - .to_block(to.as_u64()); + let from_n = from.as_u64().ok_or_else(|| { + ChainError::InvalidArgument(format!("logs_in_range: negative from {from:?}")) + })?; + let to_n = to.as_u64().ok_or_else(|| { + ChainError::InvalidArgument(format!("logs_in_range: negative to {to:?}")) + })?; + let mut filter = Filter::new().from_block(from_n).to_block(to_n); if let Some(addr) = address { filter = filter.address(addr); } diff --git a/crates/coinblast/src/worker.rs b/crates/coinblast/src/worker.rs index 7558395..92b57f2 100644 --- a/crates/coinblast/src/worker.rs +++ b/crates/coinblast/src/worker.rs @@ -19,6 +19,24 @@ use std::collections::HashSet; use std::time::Duration; use tokio_util::sync::CancellationToken; +/// Cap on `known_non_curves` to prevent unbounded growth on chains with +/// many spurious Buy/Sell-topic-shaped emitters. When over cap we drop a +/// random entry — pure perf cache, false eviction just costs one extra +/// `try_adopt` probe (audit 2026-05-13). +const KNOWN_NON_CURVES_MAX: usize = 10_000; + +/// Insert into `known_non_curves` with a hard cap. Drops one arbitrary +/// existing entry when over cap (HashSet iteration order is randomised +/// per-run by the default hasher). +fn cache_non_curve(set: &mut HashSet, addr: String) { + if set.len() >= KNOWN_NON_CURVES_MAX { + if let Some(victim) = set.iter().next().cloned() { + set.remove(&victim); + } + } + set.insert(addr); +} + /// Worker config. Defaults match the TS production indexer. #[derive(Debug, Clone)] pub struct WorkerConfig { @@ -195,12 +213,12 @@ async fn run_chunk( known_curves.insert(emitter.clone()); } Ok(false) => { - known_non_curves.insert(emitter); + cache_non_curve(known_non_curves, emitter); continue; } Err(e) => { tracing::debug!(addr = %emitter, error = %e, "orphan adopt probe failed; treating as non-curve for this run"); - known_non_curves.insert(emitter); + cache_non_curve(known_non_curves, emitter); continue; } } diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index 5515719..f71a0d3 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -15,12 +15,16 @@ use serde::{Deserialize, Serialize}; pub struct BlockHeight(pub i64); impl BlockHeight { - /// Returns the height as `u64` (chain-side native). + /// Returns the height as `u64` (chain-side native), or None when the + /// underlying value is negative. /// - /// Panics if the underlying `i64` is negative — should never happen for - /// a real chain height; PG enforces non-negative via app-level invariant. - pub fn as_u64(self) -> u64 { - u64::try_from(self.0).expect("block height non-negative") + /// The sync layer uses `BlockHeight(-1)` as a "no cursor written yet" + /// sentinel (see `crates/sync/src/cursor.rs`); a future caller hitting + /// `as_u64()` on that sentinel previously panicked + killed the worker. + /// Returning Option forces the caller to acknowledge the case (audit + /// 2026-05-13). + pub fn as_u64(self) -> Option { + u64::try_from(self.0).ok() } } @@ -71,7 +75,14 @@ mod tests { fn block_height_from_u64() { let h: BlockHeight = 42u64.into(); assert_eq!(h.0, 42); - assert_eq!(h.as_u64(), 42); + assert_eq!(h.as_u64(), Some(42)); + } + + #[test] + fn block_height_sentinel_no_panic() { + // Cursor sentinel — must not panic, must surface as None so callers + // skip rather than coerce. + assert_eq!(BlockHeight(-1).as_u64(), None); } #[test] diff --git a/crates/sync/src/block_writer.rs b/crates/sync/src/block_writer.rs index 8f6687f..b54a3aa 100644 --- a/crates/sync/src/block_writer.rs +++ b/crates/sync/src/block_writer.rs @@ -64,7 +64,7 @@ pub async fn write_block( if let Some(handle) = analytics { for t in &b.txs { let row = RawTxRow { - block_height: b.block.height.as_u64(), + block_height: b.block.height.as_u64().expect("written block heights are non-negative"), timestamp: b.block.timestamp as u64, tx_hash: t.hash.clone(), from_addr: t.from_addr.clone(), diff --git a/deny.toml b/deny.toml index 2f17406..32519f2 100644 --- a/deny.toml +++ b/deny.toml @@ -43,6 +43,15 @@ ignore = [ # `rustls-pemfile` is deprecated in favour of `rustls-pki-types`; pulled # transitively by reqwest -> rustls. No vulnerability. "RUSTSEC-2025-0134", + # `rsa` Marvin timing sidechannel via `sqlx-mysql`. We set + # `default-features = false` + explicit features (no `any`/`mysql`) so + # `cargo tree -e features` shows zero sqlx-mysql nodes and the crate + # is never compiled — but `sqlx-macros-core` lists it as an optional + # dep so the resolver still pins it in Cargo.lock. cargo-audit reads + # the lockfile, hence the false-positive. Drop this when sqlx upstream + # restructures sqlx-macros-core to not list inactive backends. Audit + # 2026-05-13. + "RUSTSEC-2023-0071", ] [sources] From 3424488888b7c033ea1a873ee07b79ab56338676 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 14 May 2026 11:49:35 +0200 Subject: [PATCH 2/5] style: cargo fmt --- crates/api/src/routes/etherscan.rs | 5 +---- crates/sync/src/block_writer.rs | 6 +++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/api/src/routes/etherscan.rs b/crates/api/src/routes/etherscan.rs index 7e3200c..deb2077 100644 --- a/crates/api/src/routes/etherscan.rs +++ b/crates/api/src/routes/etherscan.rs @@ -85,10 +85,7 @@ async fn txlist(state: &SharedState, q: EsQuery) -> EsEnvelope { }) .collect(); if !unsupported.is_empty() { - return err(format!( - "param not supported: {}", - unsupported.join(",") - )); + return err(format!("param not supported: {}", unsupported.join(","))); } // Etherscan offset = page size. We cap at 100 to align with the rest // of the indexer's pagination caps. diff --git a/crates/sync/src/block_writer.rs b/crates/sync/src/block_writer.rs index b54a3aa..1546862 100644 --- a/crates/sync/src/block_writer.rs +++ b/crates/sync/src/block_writer.rs @@ -64,7 +64,11 @@ pub async fn write_block( if let Some(handle) = analytics { for t in &b.txs { let row = RawTxRow { - block_height: b.block.height.as_u64().expect("written block heights are non-negative"), + block_height: b + .block + .height + .as_u64() + .expect("written block heights are non-negative"), timestamp: b.block.timestamp as u64, tx_hash: t.hash.clone(), from_addr: t.from_addr.clone(), From 961ae97886c8ac1058ce8cf32545a3931afdd5f2 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 14 May 2026 11:54:58 +0200 Subject: [PATCH 3/5] fix(audit): enforce loopback metrics bind + warn on dropped handle CR review on PR #30: - bin/api.rs: parse INDEXER_API_METRICS_BIND at startup; bail if not loopback (was a path back to the unauth-public-metrics exposure this PR was meant to close). - crates/api/src/lib.rs: tracing::warn when caller still passes RouterConfig.metrics_handle so the silent-drop is visible in logs. --- bin/api.rs | 12 ++++++++++++ crates/api/src/lib.rs | 13 ++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/bin/api.rs b/bin/api.rs index 88b91c5..8c205e7 100644 --- a/bin/api.rs +++ b/bin/api.rs @@ -114,8 +114,20 @@ async fn main() -> anyhow::Result<()> { // Bind /metrics on a loopback-only listener so Caddy/edge proxy can // never expose it. Operator scrapes via host-local Prometheus. + // Loopback-only is enforced at startup — `0.0.0.0` / public IPs would + // re-create the unauth-public-metrics exposure this PR closes. if !cfg.indexer_api_metrics_bind.is_empty() { let bind = cfg.indexer_api_metrics_bind.clone(); + let parsed: std::net::SocketAddr = bind + .parse() + .map_err(|e| anyhow::anyhow!("INDEXER_API_METRICS_BIND parse: {e}"))?; + if !parsed.ip().is_loopback() { + anyhow::bail!( + "INDEXER_API_METRICS_BIND={bind} must be loopback (127.0.0.1/::1). \ + /metrics is unauthenticated; binding to a non-loopback address \ + would re-expose it via the edge proxy." + ); + } let mh = metrics_handle.clone(); tokio::spawn(async move { match tokio::net::TcpListener::bind(&bind).await { diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 8c8b5ec..08602a2 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -121,9 +121,16 @@ pub fn make_router(state: AppState, cfg: RouterConfig) -> Router { // /metrics is intentionally NOT merged here. It serves on a separate // internal listener (see `metrics_router`) bound to 127.0.0.1 by the // bin so the public Caddy proxy can never expose it (audit 2026-05-13). - // Drop the handle if the caller passed one — keeps RouterConfig - // backwards-compatible while the migration to dual-listener lands. - let _ = cfg.metrics_handle; + // Old call sites that still pass metrics_handle get a one-shot warn so + // the silent-drop is visible in logs; field is kept for ABI compat + // until next breaking release. + if cfg.metrics_handle.is_some() { + tracing::warn!( + "RouterConfig.metrics_handle is set but the public router no longer mounts /metrics. \ + Spawn the internal listener via metrics_router() instead — see bin/api.rs. \ + The handle is being ignored; this field will be removed in a future release." + ); + } app.layer(from_fn(observability::track_request)) .layer(GovernorLayer::new(governor)) From de37b7478f6b9de812c24c1bafd0274ee124edd5 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 14 May 2026 12:02:26 +0200 Subject: [PATCH 4/5] fix(sync): block_writer best-effort height extraction (no panic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR review on PR #30: - block_writer.rs: replace .expect() on block height conversion with match → warn-and-skip pattern. Analytics is best-effort already (comment line 62-63); don't escalate a never-hit-but-possible conversion miss to a worker panic. --- crates/sync/src/block_writer.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/sync/src/block_writer.rs b/crates/sync/src/block_writer.rs index 1546862..94adcf4 100644 --- a/crates/sync/src/block_writer.rs +++ b/crates/sync/src/block_writer.rs @@ -62,13 +62,23 @@ pub async fn write_block( // Best-effort analytics push, after the SQL boundary so a failed flusher // can't roll back our data. if let Some(handle) = analytics { + // Should never hit (writer only runs on heights coming back from + // resolved blocks, never the -1 sentinel from the cursor) but keep + // analytics non-fatal: warn + skip the row, don't panic the loop. + let block_height = match b.block.height.as_u64() { + Some(h) => h, + None => { + tracing::warn!( + height = ?b.block.height, + "analytics: skipping row — block height not convertible to u64 \ + (cursor sentinel reached writer; this should not happen)" + ); + return Ok(()); + } + }; for t in &b.txs { let row = RawTxRow { - block_height: b - .block - .height - .as_u64() - .expect("written block heights are non-negative"), + block_height, timestamp: b.block.timestamp as u64, tx_hash: t.hash.clone(), from_addr: t.from_addr.clone(), From e2da72e6b07ff3bec8c6960c6610dc3b3cd9944b Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 14 May 2026 12:33:43 +0200 Subject: [PATCH 5/5] fix(clippy): collapse nested if in cache_non_curve CR-pushed clippy strict-warnings: collapsible_if on the LRU eviction guard. Use let-chain syntax so the .cloned() doesn't fire when set is under cap. --- crates/coinblast/src/worker.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/coinblast/src/worker.rs b/crates/coinblast/src/worker.rs index 92b57f2..95e59e5 100644 --- a/crates/coinblast/src/worker.rs +++ b/crates/coinblast/src/worker.rs @@ -29,10 +29,10 @@ const KNOWN_NON_CURVES_MAX: usize = 10_000; /// existing entry when over cap (HashSet iteration order is randomised /// per-run by the default hasher). fn cache_non_curve(set: &mut HashSet, addr: String) { - if set.len() >= KNOWN_NON_CURVES_MAX { - if let Some(victim) = set.iter().next().cloned() { - set.remove(&victim); - } + if set.len() >= KNOWN_NON_CURVES_MAX + && let Some(victim) = set.iter().next().cloned() + { + set.remove(&victim); } set.insert(addr); }