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:
commit
0a969b296b
7 changed files with 251 additions and 8 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)>> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
106
src/rpc/validate.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue