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:
2026-07-16 18:59:30 -04:00
parent 237e62d4be
commit 4fc0790fe7
20 changed files with 2762 additions and 1022 deletions

View File

@@ -4,13 +4,15 @@ use sqlite::{Connection, Value};
fn test_sql_injection_via_push_err_update() {
// Create an in-memory database and the required table
let db = Connection::open(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);"
);
let _ =
db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);");
// Insert a dummy transaction
let mut stmt = db.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);").unwrap();
stmt.bind((1, Value::String("dummy_txid".to_string()))).unwrap();
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);")
.unwrap();
stmt.bind((1, Value::String("dummy_txid".to_string())))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
stmt.bind((3, Value::String("".to_string()))).unwrap();
let _ = stmt.next();
@@ -23,13 +25,18 @@ fn test_sql_injection_via_push_err_update() {
// Execute the fixed query using parameter binding (safe)
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_error.to_string()))).unwrap();
stmt.bind((1, Value::String(malicious_error.to_string())))
.unwrap();
stmt.bind((2, Value::String(txid.to_string()))).unwrap();
let _ = stmt.next();
// Verify the table still exists and the row was updated correctly
let mut check = db.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;").unwrap();
check.bind((1, Value::String("dummy_txid".to_string()))).unwrap();
let mut check = db
.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String("dummy_txid".to_string())))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
let push_err: String = check.read("push_err").unwrap();
@@ -46,14 +53,15 @@ fn test_sql_injection_via_push_err_update() {
#[test]
fn test_sql_injection_via_txid_update() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);"
);
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
// Insert multiple dummy transactions
for i in 0..3 {
let mut stmt = db.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);").unwrap();
stmt.bind((1, Value::String(format!("txid_{}", i)))).unwrap();
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
.unwrap();
stmt.bind((1, Value::String(format!("txid_{}", i))))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
let _ = stmt.next();
}
@@ -64,28 +72,38 @@ fn test_sql_injection_via_txid_update() {
// The fixed query parameterizes the txid, so this should only update zero rows
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string()))).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string())))
.unwrap();
let _ = stmt.next();
// Verify no rows were updated (status should still be 0 for all)
for i in 0..3 {
let mut check = db.prepare("SELECT status FROM tbl_tx WHERE txid = ?;").unwrap();
check.bind((1, Value::String(format!("txid_{}", i)))).unwrap();
let mut check = db
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String(format!("txid_{}", i))))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
assert_eq!(status, 0, "Row txid_{} should not be updated by malicious txid", i);
assert_eq!(
status, 0,
"Row txid_{} should not be updated by malicious txid",
i
);
}
}
#[test]
fn test_sql_injection_via_txid_with_comment() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);"
);
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
let mut stmt = db.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);").unwrap();
stmt.bind((1, Value::String("safe_txid".to_string()))).unwrap();
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
.unwrap();
stmt.bind((1, Value::String("safe_txid".to_string())))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
let _ = stmt.next();
@@ -94,18 +112,25 @@ fn test_sql_injection_via_txid_with_comment() {
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string()))).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string())))
.unwrap();
let _ = stmt.next();
// Verify the original row was NOT updated (because it was looking for the full malicious string)
// and no rows have status 99 (the injected update did not execute)
let mut check = db.prepare("SELECT status FROM tbl_tx WHERE txid = ?;").unwrap();
check.bind((1, Value::String("safe_txid".to_string()))).unwrap();
let mut check = db
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String("safe_txid".to_string())))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
assert_eq!(status, 0, "Original row should not be updated");
let mut count_stmt = db.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;").unwrap();
let mut count_stmt = db
.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;")
.unwrap();
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
let count: i64 = count_stmt.read(0).unwrap();
assert_eq!(count, 0, "No rows should have status 99");