From 6262e5accf7f5968fa490c9cdd8c063b3a43a789 Mon Sep 17 00:00:00 2001 From: jakobsn Date: Tue, 11 Aug 2026 11:25:33 +0200 Subject: [PATCH] A malformed token id must come back as a 400, not take the process down. --- src/db/cauldron/poolvisitor.rs | 10 +++- src/rpc/apy/mod.rs | 6 +- src/rpc/contract.rs | 5 +- src/rpc/mod.rs | 1 + src/rpc/pool.rs | 80 ++++++++++++++++++++++++- src/rpc/tvl.rs | 51 +++++++++++++++- src/rpc/validate.rs | 106 +++++++++++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 src/rpc/validate.rs diff --git a/src/db/cauldron/poolvisitor.rs b/src/db/cauldron/poolvisitor.rs index 3bda5d4..8c1ea01 100644 --- a/src/db/cauldron/poolvisitor.rs +++ b/src/db/cauldron/poolvisitor.rs @@ -5,7 +5,7 @@ use sqlx::{Row, SqlitePool}; -use anyhow::Result; +use anyhow::{Context, Result}; use bitcoincash::{TokenID, Txid}; use riftenlabs_defi::chainutil::OutPointHash; @@ -190,12 +190,16 @@ pub(crate) async fn db_visit_pool_entries( q = q.bind(*tp); } q = q.bind(withdraw_time_filter as i64); + // These filters carry user-supplied strings. Never panic on them: a malformed + // token id or pkh must surface as an error the route can turn into a 400, + // not take down the process. if let Some(token_id) = &filters.token_id { - let token_blob = display_hex_to_blob::(token_id).expect("valid token hex"); + let token_blob = display_hex_to_blob::(token_id) + .context("token_id filter is not a 32-byte hex token id")?; q = q.bind(token_blob); } if let Some(owner) = &filters.owner { - let owner_blob = hex::decode(owner).expect("valid owner hex"); + let owner_blob = hex::decode(owner).context("owner filter is not valid hex")?; q = q.bind(owner_blob); } diff --git a/src/rpc/apy/mod.rs b/src/rpc/apy/mod.rs index 56d1571..646487e 100644 --- a/src/rpc/apy/mod.rs +++ b/src/rpc/apy/mod.rs @@ -18,6 +18,7 @@ use crate::{ def::PoolID, rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}, rpc::response::{cached_ok, CACHE_AGGREGATE}, + rpc::validate::{parse_pkh, parse_token_id}, timeutil::time_now, }; use serde_json::json; @@ -103,7 +104,10 @@ pub async fn aggregate_apy( .await .map_err(db_error)? } else { - get_pool_period_snapshot(&db.cauldron_r, token, pkh, start, end) + let token = token.map(parse_token_id).transpose()?; + let pkh = pkh.map(parse_pkh).transpose()?; + + get_pool_period_snapshot(&db.cauldron_r, token.as_deref(), pkh.as_deref(), start, end) .await .map_err(db_error)? }; diff --git a/src/rpc/contract.rs b/src/rpc/contract.rs index 389c5f6..ade459c 100644 --- a/src/rpc/contract.rs +++ b/src/rpc/contract.rs @@ -15,6 +15,7 @@ use crate::{ db::{blob::display_hex_to_blob, DB}, rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}, rpc::response::{cached_ok, CACHE_AGGREGATE}, + rpc::validate::parse_token_id, timeutil::time_now, }; @@ -108,7 +109,9 @@ pub async fn contract_count_all(conn: &State) -> CachedApiResult { /// ``` #[get("/contract/count/")] pub async fn contract_count_token(token: &str, conn: &State) -> CachedApiResult { - let count = db_contract_count_by_token(&conn.cauldron_r, token) + let token = parse_token_id(token)?; + + let count = db_contract_count_by_token(&conn.cauldron_r, &token) .await .map_err(db_error)?; Ok(cached_ok(json!(count), CACHE_AGGREGATE)) diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 364f589..61a7ef7 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -28,6 +28,7 @@ pub mod tokentoken; pub mod tvl; pub mod tx; pub mod user; +pub mod validate; pub mod volume; async fn all_time_volume(pool: &SqlitePool, end_timestamp: u64) -> Result> { diff --git a/src/rpc/pool.rs b/src/rpc/pool.rs index a15c5b2..58bb129 100644 --- a/src/rpc/pool.rs +++ b/src/rpc/pool.rs @@ -17,6 +17,7 @@ use crate::{ def::PoolID, rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult}, rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_NONE}, + rpc::validate::{parse_pkh, parse_token_id}, timeutil::time_now, }; use anyhow::Result; @@ -110,13 +111,16 @@ pub async fn list_active_pools( )); } + let token = token.map(parse_token_id).transpose()?; + let pkh = pkh.map(parse_pkh).transpose()?; + let mut active_pool_list = ActivePoolList::default(); db_visit_pool_entries( &conn.cauldron_r, &mut active_pool_list, PoolFilters { - token_id: token.map(|s| s.to_string()), - owner: pkh.map(|s| s.to_string()), + token_id: token, + owner: pkh, timestamp_lt: None, timestamp_lte: None, timestamp_gt: None, @@ -233,3 +237,75 @@ pub async fn pool_id_from_utxo(txid: &str, n: u32, conn: &State) -> CachedAp )), } } + +#[cfg(test)] +mod tests { + use crate::db::cauldron::{pool as pooldb, tx, utxo_funding, utxo_spending}; + use crate::utiltest::mock_db_pool; + use rocket::http::Status; + use rocket::local::asynchronous::Client; + use rocket::routes; + + async fn setup_empty_db(pool: sqlx::SqlitePool) { + utxo_funding::create_table(&pool).await; + utxo_spending::create_table(&pool).await; + tx::create_table(&pool).await; + pooldb::create_table(&pool).await; + pooldb::dummy_init_seq(); + } + + /// Malformed `token` / `pkh` must come back as 400s, not take the process down. + /// + /// Regression: both were fed straight into the query builder, which + /// `.expect()`ed on the hex. Combined with the global panic hook + /// (`process::exit(1)`), one bad query string killed the daemon. + #[rocket::async_test] + async fn test_list_active_pools_rejects_malformed_params() { + let mock_db = mock_db_pool(setup_empty_db).await; + + let rocket = rocket::build() + .manage(mock_db) + .mount("/api", routes![super::list_active_pools]); + let client = Client::tracked(rocket) + .await + .expect("valid rocket instance"); + + let valid_token = "0000000000000000000000000000000000000000000000000000000000000000"; + let valid_pkh = "36c0020dd39e7cd66c21f237dc53d384661a557f"; + let double_encoded = hex::encode(valid_token); + + for bad in [double_encoded.as_str(), "notahex", "abcd"] { + let response = client + .get(format!("/api/pool/active?token={bad}")) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::BadRequest, + "token `{bad}` should be rejected" + ); + } + + // Non-hex, and hex of the wrong length for a 20-byte pkh. + for bad in ["zz", "36c0020d", ""] { + let response = client + .get(format!("/api/pool/active?pkh={bad}")) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::BadRequest, + "pkh `{bad}` should be rejected" + ); + } + + // Well-formed params reach the query layer and return an empty list. + for good in [ + format!("/api/pool/active?token={valid_token}"), + format!("/api/pool/active?pkh={valid_pkh}"), + ] { + let response = client.get(good.clone()).dispatch().await; + assert_eq!(response.status(), Status::Ok, "`{good}` should succeed"); + } + } +} diff --git a/src/rpc/tvl.rs b/src/rpc/tvl.rs index 6a5ebf8..e30e6ae 100644 --- a/src/rpc/tvl.rs +++ b/src/rpc/tvl.rs @@ -19,6 +19,7 @@ use crate::db::{ }; use crate::rpc::err::{db_error, CachedApiResult}; use crate::rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_IMMUTABLE}; +use crate::rpc::validate::parse_token_id; #[derive(Default)] pub struct TvlByTokenVisitor { @@ -226,7 +227,9 @@ pub async fn valuelocked_token( time: Option, conn: &State, ) -> CachedApiResult { - let (sats, token_amount) = get_token_tvl(&conn.cauldron_r, time, token) + let token = parse_token_id(token)?; + + let (sats, token_amount) = get_token_tvl(&conn.cauldron_r, time, &token) .await .map_err(db_error)?; @@ -453,4 +456,50 @@ pub mod tests { .unwrap(); assert_eq!(result, (0, 0)); // No data for non-existent token } + + /// A malformed token id must come back as a 400, not take the process down. + /// + /// Regression: the query builder used to `.expect()` on this hex, and the + /// global panic hook turns any handler panic into `process::exit(1)`, so a + /// single bad request killed the daemon. + #[rocket::async_test] + async fn test_valuelocked_token_rejects_malformed_token_id() { + let mock_db = mock_db_pool(setup_mock_db).await; + + let rocket = rocket::build() + .manage(mock_db) + .mount("/api", routes![super::valuelocked_token]); + let client = Client::tracked(rocket) + .await + .expect("valid rocket instance"); + + let valid = "0000000000000000000000000000000000000000000000000000000000000000"; + // The exact shape seen in production: a token id hex-encoded a second time. + let double_encoded = hex::encode(valid); + + for bad in [double_encoded.as_str(), "notahex", "abcd", "%20"] { + let response = client + .get(format!("/api/valuelocked/{bad}")) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::BadRequest, + "token `{bad}` should be rejected" + ); + + let body: Value = serde_json::from_str(&response.into_string().await.unwrap()).unwrap(); + assert_eq!(body["error"]["code"], "INVALID_TOKEN_ID"); + } + + // A well-formed token id is unaffected. + let response = client + .get(format!("/api/valuelocked/{valid}")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let body: Value = serde_json::from_str(&response.into_string().await.unwrap()).unwrap(); + assert_eq!(body["satoshis"], 600_000); + } } diff --git a/src/rpc/validate.rs b/src/rpc/validate.rs new file mode 100644 index 0000000..9bdb17b --- /dev/null +++ b/src/rpc/validate.rs @@ -0,0 +1,106 @@ +// Copyright (C) 2026 Whiterun LLC +// +// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html + +//! Route-boundary validation for user-supplied hex parameters. +//! +//! Any route that forwards a raw token id or pkh into a DB query runs it through +//! here first, so malformed input becomes a 400 with a useful message instead of +//! surfacing as an opaque 500 from the query layer. + +use bitcoincash::TokenID; +use rocket::{response::status::Custom, serde::json::Json}; +use serde_json::Value; + +use super::err::{bad_request, ApiErrorCode}; + +/// Length of a P2PKH public key hash, in bytes. +const PKH_LEN: usize = 20; + +/// Validate a display-format token id, returning it normalised to lowercase hex. +pub fn parse_token_id(token: &str) -> Result>> { + token + .parse::() + .map(|t| t.to_string()) + .map_err(|e| { + bad_request( + ApiErrorCode::InvalidTokenId, + &format!("Invalid token ID: {e}"), + ) + }) +} + +/// Validate a 20-byte public key hash in hex, returning it normalised to lowercase hex. +pub fn parse_pkh(pkh: &str) -> Result>> { + let bytes = hex::decode(pkh).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid pkh: {e}"), + ) + })?; + + if bytes.len() != PKH_LEN { + return Err(bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid pkh: expected {PKH_LEN} bytes, got {}", bytes.len()), + )); + } + + Ok(hex::encode(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::http::Status; + + const VALID_TOKEN: &str = "ae15ad3916c2eb61339be49355020d918906569f0e97b7f265d83558f2c16f40"; + const VALID_PKH: &str = "36c0020dd39e7cd66c21f237dc53d384661a557f"; + + #[test] + fn token_id_roundtrips_and_normalises_case() { + assert_eq!(parse_token_id(VALID_TOKEN).unwrap(), VALID_TOKEN); + assert_eq!( + parse_token_id(&VALID_TOKEN.to_uppercase()).unwrap(), + VALID_TOKEN + ); + } + + #[test] + fn token_id_rejects_malformed_input() { + // The shape that crashed the server: a token id hex-encoded a second time. + let double_encoded = hex::encode(VALID_TOKEN); + assert_eq!(double_encoded.len(), 128); + + for bad in [ + double_encoded.as_str(), + "notahex", + "", + "ae15ad39", // too short + "zz15ad3916c2eb61339be49355020d918906569f0e97b7f265d83558f2c16f40", + ] { + let err = parse_token_id(bad).expect_err("should reject {bad}"); + assert_eq!(err.0, Status::BadRequest); + } + } + + #[test] + fn pkh_roundtrips_and_normalises_case() { + assert_eq!(parse_pkh(VALID_PKH).unwrap(), VALID_PKH); + assert_eq!(parse_pkh(&VALID_PKH.to_uppercase()).unwrap(), VALID_PKH); + } + + #[test] + fn pkh_rejects_malformed_input() { + for bad in [ + "zz", + "", + "36c0020d", // wrong length + "36c0020dd39e7cd66c21f237dc53d384661a557f00", // wrong length + ] { + let err = parse_pkh(bad).expect_err("should reject {bad}"); + assert_eq!(err.0, Status::BadRequest); + } + } +}