Merge branch 'dontPanich' into 'master'

A malformed token id must come back as a 400, not take the process down.

See merge request riftenlabs/riftenlabs-indexer!100
This commit is contained in:
jakobsn 2026-08-11 09:31:04 +00:00
commit 0a969b296b
7 changed files with 251 additions and 8 deletions

View file

@ -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<T: PoolVisitor>(
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::<TokenID>(token_id).expect("valid token hex");
let token_blob = display_hex_to_blob::<TokenID>(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);
}

View file

@ -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)?
};

View file

@ -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<DB>) -> CachedApiResult<Value> {
/// ```
#[get("/contract/count/<token>")]
pub async fn contract_count_token(token: &str, conn: &State<DB>) -> CachedApiResult<Value> {
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))

View file

@ -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<Vec<(String, i64)>> {

View file

@ -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<DB>) -> 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");
}
}
}

View file

@ -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<usize>,
conn: &State<DB>,
) -> CachedApiResult<Value> {
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);
}
}

106
src/rpc/validate.rs Normal file
View file

@ -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<String, Custom<Json<Value>>> {
token
.parse::<TokenID>()
.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<String, Custom<Json<Value>>> {
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);
}
}
}