security: fix audit points 5-9 + optimize echo_push/info endpoints
- Point 5 (SSRF): Add URL validation for WELIST_SERVER_URL (src/validation.rs) - Point 6 (DB Access): Add DB path validation, symlink check, WAL mode (open_db) - Point 8 (HTTPS): Extract nginx config, add deployment checklist, bind warnings - Point 9 (Input Validation): Add NETWORKS check (404 for unknown), txid 64-hex validation - Optimize echo_push: parse transactions outside DB lock, batch duplicate check, N+1 xpub lookup eliminated via HashSet cache - Optimize echo_info: derive BIP32 address outside DB lock, minimize lock duration - Fix echo_stats SQL injection via parameter binding + add idx_stats_chain index - New regression tests: ssrf_tests, db_path_validation, input_validation_tests
This commit is contained in:
168
src/db.rs
168
src/db.rs
@@ -1,5 +1,121 @@
|
||||
use log::{error, info, trace};
|
||||
use sqlite::{Connection, Error, State, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
/// Check which txids are already present in the database in a single batch query.
|
||||
/// Returns a HashSet of txids that already exist (duplicates).
|
||||
/// This is O(1) per query regardless of the number of txids, replacing the N+1 pattern.
|
||||
pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSet<String>, Error> {
|
||||
if txids.is_empty() {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
|
||||
// Build a single query with all txids using IN clause placeholders
|
||||
// SQLite supports up to 1000 parameters per statement, so we chunk for safety
|
||||
let mut duplicates = HashSet::new();
|
||||
let chunk_size = 500; // Safe chunk size for SQLite parameters
|
||||
|
||||
for chunk in txids.chunks(chunk_size) {
|
||||
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||
let sql = format!("SELECT txid FROM tbl_tx WHERE txid IN ({})", placeholders);
|
||||
let mut stmt = db.prepare(sql)?;
|
||||
|
||||
for (i, txid) in chunk.iter().enumerate() {
|
||||
stmt.bind((i + 1, Value::String(txid.clone())))?;
|
||||
}
|
||||
|
||||
while let Ok(State::Row) = stmt.next() {
|
||||
if let Ok(txid) = stmt.read::<String, _>("txid") {
|
||||
duplicates.insert(txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(duplicates)
|
||||
}
|
||||
|
||||
/// Validates and opens the SQLite database, enforcing security best practices:
|
||||
/// - Path must not contain `..` (directory traversal).
|
||||
/// - Absolute paths must not target known system directories.
|
||||
/// - If the file exists, it must be a regular file (not a symlink or device).
|
||||
/// - WAL journal mode is enabled for safe concurrent access.
|
||||
/// - Synchronous is set to NORMAL for performance with safety.
|
||||
///
|
||||
/// Returns `Err` on validation failure or open error to prevent panics.
|
||||
pub fn open_db(path: &str) -> Result<Connection, String> {
|
||||
let p = Path::new(path);
|
||||
|
||||
// Prevent directory traversal
|
||||
for component in p.components() {
|
||||
if component == std::path::Component::ParentDir {
|
||||
return Err("Database path may not contain '..'".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If absolute, block known sensitive system directories
|
||||
if p.is_absolute() {
|
||||
let path_str = p.to_str().unwrap_or("");
|
||||
let forbidden = [
|
||||
"/etc", "/proc", "/sys", "/dev", "/usr", "/bin", "/sbin", "/lib", "/opt",
|
||||
];
|
||||
for prefix in &forbidden {
|
||||
if path_str.starts_with(prefix) {
|
||||
return Err(
|
||||
format!(
|
||||
"Absolute database path under {} is forbidden",
|
||||
prefix
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If file exists, must be a regular file (not a symlink, device, etc.)
|
||||
if p.exists() {
|
||||
if p.is_symlink() {
|
||||
return Err(
|
||||
"Database path must not be a symlink".to_string()
|
||||
);
|
||||
}
|
||||
let metadata = std::fs::metadata(p)
|
||||
.map_err(|e| format!("Cannot access database file metadata: {}", e))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(
|
||||
"Database path must point to a regular file, not a directory or device".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = sqlite::open(path)
|
||||
.map_err(|e| format!("Failed to open SQLite database: {}", e))?;
|
||||
|
||||
conn.execute("PRAGMA journal_mode = WAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL mode: {}", e))?;
|
||||
conn.execute("PRAGMA synchronous = NORMAL;")
|
||||
.map_err(|e| format!("Failed to set synchronous NORMAL: {}", e))?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Loads all known addresses for a given xpub into a HashSet for fast
|
||||
/// in-memory lookup during transaction validation (replaces N+1 query).
|
||||
pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?"
|
||||
)?;
|
||||
stmt.bind((1, Value::String(xpub.to_string())))?;
|
||||
let mut addresses = HashSet::new();
|
||||
while let Ok(State::Row) = stmt.next() {
|
||||
match stmt.read::<String, _>("address") {
|
||||
Ok(addr) => { addresses.insert(addr); }
|
||||
Err(e) => {
|
||||
error!("Failed to read address column: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
pub fn create_database(db: &Connection) {
|
||||
info!("database sanity check");
|
||||
@@ -17,6 +133,9 @@ pub fn create_database(db: &Connection) {
|
||||
let _ = db.execute("CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub)");
|
||||
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_address (address TEXT PRIMARY_KEY, path TEXT NOT NULL, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, xpub INTEGER,remote_address TEXT);");
|
||||
|
||||
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_stats (report_date TEXT, chain TEXT, totals INTEGER, waiting INTEGER, sent INTEGER, failed INTEGER, waiting_profit INTEGER, sent_profit INTEGER, missed_profit INTEGER, unique_inputs INTEGER);");
|
||||
let _ = db.execute("CREATE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);");
|
||||
|
||||
let _ = db.execute("UPDATE tbl_tx set network='bitcoin' where network='mainnet');");
|
||||
}
|
||||
/*
|
||||
@@ -109,28 +228,24 @@ pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String)
|
||||
return (0, 0);
|
||||
}
|
||||
match stmt.next() {
|
||||
Ok(State::Row) => {
|
||||
match stmt.read::<i64, _>("path_idx") {
|
||||
Ok(next) => match stmt.read::<i64, _>("id") {
|
||||
Ok(id) => (id, next),
|
||||
Err(e) => {
|
||||
error!("Failed to read id column: {}", e);
|
||||
(0, 0)
|
||||
}
|
||||
},
|
||||
Ok(State::Row) => match stmt.read::<i64, _>("path_idx") {
|
||||
Ok(next) => match stmt.read::<i64, _>("id") {
|
||||
Ok(id) => (id, next),
|
||||
Err(e) => {
|
||||
error!("Failed to read path_idx column: {}", e);
|
||||
error!("Failed to read id column: {}", e);
|
||||
(0, 0)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to read path_idx column: {}", e);
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to execute xpub index update: {}", e);
|
||||
(0, 0)
|
||||
}
|
||||
Ok(State::Done) => {
|
||||
(0, 0)
|
||||
}
|
||||
Ok(State::Done) => (0, 0),
|
||||
}
|
||||
}
|
||||
pub fn save_new_address(
|
||||
@@ -140,8 +255,10 @@ pub fn save_new_address(
|
||||
path: &String,
|
||||
remote_addr: &String,
|
||||
) {
|
||||
let mut stmt = match db.prepare("INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
|
||||
") {
|
||||
let mut stmt = match db.prepare(
|
||||
"INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
|
||||
",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to prepare address insert statement: {}", e);
|
||||
@@ -241,21 +358,22 @@ pub fn execute_insert(
|
||||
pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error> {
|
||||
let mut stmt = db
|
||||
.prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;")
|
||||
.map_err(|e| { error!("Failed to prepare statement: {}", e); e })?;
|
||||
.map_err(|e| {
|
||||
error!("Failed to prepare statement: {}", e);
|
||||
e
|
||||
})?;
|
||||
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
|
||||
error!("Failed to bind network parameter: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
match stmt.next() {
|
||||
Ok(State::Row) => {
|
||||
match stmt.read::<i64, _>("total_number") {
|
||||
Ok(val) => Ok(val),
|
||||
Err(e) => {
|
||||
error!("Failed to read total_number column: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
Ok(State::Row) => match stmt.read::<i64, _>("total_number") {
|
||||
Ok(val) => Ok(val),
|
||||
Err(e) => {
|
||||
error!("Failed to read total_number column: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(sqlite::State::Done) => Ok(0),
|
||||
Err(err) => {
|
||||
error!("Failed to execute query: {}", err);
|
||||
|
||||
Reference in New Issue
Block a user