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:
102
tests/db_path_validation.rs
Normal file
102
tests/db_path_validation.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use bal_server::db::open_db;
|
||||
use sqlite::State;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_open_db_blocks_traversal() {
|
||||
let res = open_db("../etc/passwd");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Path with '..' should be rejected"
|
||||
);
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for traversal path"),
|
||||
};
|
||||
assert!(err.contains("'..'"), "Error should mention directory traversal: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_blocks_forbidden_absolute() {
|
||||
for path in ["/etc/passwd", "/proc/self/mem", "/dev/null", "/usr/bin/ls"] {
|
||||
let res = open_db(path);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Absolute path {} should be rejected", path
|
||||
);
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for forbidden path {}", path),
|
||||
};
|
||||
assert!(
|
||||
err.contains("forbidden"),
|
||||
"Error should mention forbidden prefix: {}", err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_allows_relative() {
|
||||
let test_path = "tmp_test_bal.db";
|
||||
let _ = fs::remove_file(test_path);
|
||||
let res = open_db(test_path);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"Valid relative path should be allowed"
|
||||
);
|
||||
let db = res.unwrap();
|
||||
drop(db);
|
||||
let _ = fs::remove_file(test_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_wal_pragmas_set() {
|
||||
let test_path = "tmp_test_wal.db";
|
||||
let _ = fs::remove_file(test_path);
|
||||
let _ = fs::remove_file(format!("{}-shm", test_path));
|
||||
let _ = fs::remove_file(format!("{}-wal", test_path));
|
||||
|
||||
let db = open_db(test_path).expect("Should open DB");
|
||||
let mut stmt = db.prepare("PRAGMA journal_mode;").unwrap();
|
||||
if let Ok(State::Row) = stmt.next() {
|
||||
let mode: String = stmt.read(0).unwrap();
|
||||
assert_eq!(
|
||||
mode, "wal",
|
||||
"SQLite journal mode should be WAL"
|
||||
);
|
||||
} else {
|
||||
panic!("Could not read journal_mode pragma");
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(test_path);
|
||||
let _ = fs::remove_file(format!("{}-shm", test_path));
|
||||
let _ = fs::remove_file(format!("{}-wal", test_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_rejects_symlink() {
|
||||
let real = "tmp_test_real.db";
|
||||
let link = "tmp_test_link.db";
|
||||
let _ = fs::remove_file(real);
|
||||
let _ = fs::remove_file(link);
|
||||
fs::File::create(real).unwrap();
|
||||
fs::soft_link(real, link).unwrap();
|
||||
|
||||
let res = open_db(link);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Symlink DB path should be rejected"
|
||||
);
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for symlink"),
|
||||
};
|
||||
assert!(
|
||||
err.contains("symlink"),
|
||||
"Error should mention symlink: {}", err
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(real);
|
||||
let _ = fs::remove_file(link);
|
||||
}
|
||||
73
tests/input_validation_tests.rs
Normal file
73
tests/input_validation_tests.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use bal_server::db::{get_all_addresses_by_xpub, open_db};
|
||||
use sqlite::Value;
|
||||
|
||||
fn setup_db_with_xpub() -> sqlite::Connection {
|
||||
let db = open_db(":memory:").unwrap();
|
||||
let _ = db.execute(
|
||||
"CREATE TABLE tbl_xpub (id INTEGER PRIMARY KEY, network TEXT, xpub TEXT, path_idx INTEGER DEFAULT -1);"
|
||||
);
|
||||
let _ = db.execute(
|
||||
"CREATE TABLE tbl_address (address TEXT PRIMARY KEY, path TEXT, xpub INTEGER, remote_address TEXT);"
|
||||
);
|
||||
// Insert test xpub
|
||||
let mut stmt = db.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);").unwrap();
|
||||
stmt.bind((1, Value::Integer(1))).unwrap();
|
||||
stmt.bind((2, Value::String("testnet".to_string()))).unwrap();
|
||||
stmt.bind((3, Value::String("tpub_test".to_string()))).unwrap();
|
||||
let _ = stmt.next();
|
||||
drop(stmt);
|
||||
// Insert test addresses
|
||||
for addr in ["addr1", "addr2", "addr3"] {
|
||||
let mut stmt = db.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);").unwrap();
|
||||
stmt.bind((1, Value::String(addr.to_string()))).unwrap();
|
||||
stmt.bind((2, Value::String("m/0/1".to_string()))).unwrap();
|
||||
stmt.bind((3, Value::Integer(1))).unwrap();
|
||||
let _ = stmt.next();
|
||||
drop(stmt);
|
||||
}
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_addresses_by_xpub_returns_known() {
|
||||
let db = setup_db_with_xpub();
|
||||
let addresses = get_all_addresses_by_xpub(&db, "tpub_test").unwrap();
|
||||
assert!(addresses.contains("addr1"));
|
||||
assert!(addresses.contains("addr2"));
|
||||
assert!(addresses.contains("addr3"));
|
||||
assert_eq!(addresses.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_addresses_by_xpub_empty_for_missing() {
|
||||
let db = setup_db_with_xpub();
|
||||
let addresses = get_all_addresses_by_xpub(&db, "tpub_nonexistent").unwrap();
|
||||
assert!(addresses.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_unknown_returns_404() {
|
||||
// This is a code-level check; the actual HTTP test requires actix-web setup.
|
||||
// Verify that the NETWORKS constant includes the expected set.
|
||||
let networks = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
|
||||
for n in networks {
|
||||
assert!(networks.contains(&n), "{} should be a valid network", n);
|
||||
}
|
||||
assert!(!networks.contains(&"attacker"), "attacker should not be a valid network");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_txid_validation_is_hex_64() {
|
||||
let valid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(valid.len(), 64);
|
||||
|
||||
let too_short = "abcdef1234567890";
|
||||
assert!(too_short.len() != 64);
|
||||
|
||||
let non_hex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789g";
|
||||
assert!(!non_hex.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
|
||||
let with_dot = ".abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
assert!(!with_dot.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use sqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::collections::HashMap;
|
||||
use sqlite::Connection;
|
||||
|
||||
#[test]
|
||||
fn test_mutex_poisoning_recovery() {
|
||||
@@ -28,16 +28,22 @@ fn test_mutex_poisoning_recovery() {
|
||||
#[test]
|
||||
fn test_db_null_unwrap_or() {
|
||||
let db = Connection::open(":memory:").unwrap();
|
||||
let _ = db.execute("CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);");
|
||||
let _ = db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
|
||||
|
||||
let _ = db.execute(
|
||||
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
|
||||
);
|
||||
let _ =
|
||||
db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
|
||||
|
||||
let mut found_value = None;
|
||||
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
|
||||
let row: HashMap<_, _> = pairs.into_iter().map(|(k,v)| (k.to_string(), v.map(|s| s))).collect();
|
||||
let row: HashMap<_, _> = pairs
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.map(|s| s)))
|
||||
.collect();
|
||||
let totals = row["totals"].clone().unwrap_or("0").to_string();
|
||||
found_value = Some(totals);
|
||||
true
|
||||
});
|
||||
|
||||
|
||||
assert_eq!(found_value.unwrap(), "0");
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_gitignore_protection_env() {
|
||||
let gitignore = fs::read_to_string(".gitignore")
|
||||
.expect(".gitignore file not found in project root");
|
||||
|
||||
let gitignore =
|
||||
fs::read_to_string(".gitignore").expect(".gitignore file not found in project root");
|
||||
|
||||
// Check that .env and .pem files are blocked
|
||||
let required_patterns = vec![
|
||||
".env",
|
||||
@@ -21,12 +21,13 @@ fn test_gitignore_protection_env() {
|
||||
"ec.key",
|
||||
"chiave_privata.key",
|
||||
];
|
||||
|
||||
|
||||
for pattern in required_patterns {
|
||||
let has_exact = gitignore.contains(&pattern);
|
||||
let has_wildcard = gitignore.contains(&format!("*.env.local")) || gitignore.contains(&format!(".env.local"));
|
||||
let has_wildcard = gitignore.contains(&format!("*.env.local"))
|
||||
|| gitignore.contains(&format!(".env.local"));
|
||||
let has_env = gitignore.contains("*.env") || gitignore.contains(".env");
|
||||
|
||||
|
||||
// For .env.local, either .env.local or *.env.local is acceptable
|
||||
let is_env_local = pattern == "*.env.local" || pattern == ".env.local";
|
||||
if is_env_local {
|
||||
@@ -58,7 +59,7 @@ fn test_gitignore_protection_env() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
println!(".gitignore properly protects .env, .pem, and .key files");
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ fn test_no_private_key_in_git() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
assert!(
|
||||
gitignore.contains("private_key.pem"),
|
||||
".gitignore must block private_key.pem"
|
||||
@@ -81,34 +82,32 @@ fn test_no_private_key_in_git() {
|
||||
gitignore.contains("privkey.pem"),
|
||||
".gitignore must block privkey.pem"
|
||||
);
|
||||
assert!(
|
||||
gitignore.contains("ec.key"),
|
||||
".gitignore must block ec.key"
|
||||
);
|
||||
assert!(gitignore.contains("ec.key"), ".gitignore must block ec.key");
|
||||
assert!(
|
||||
gitignore.contains("chiave_privata.key"),
|
||||
".gitignore must block chiave_privata.key"
|
||||
);
|
||||
|
||||
|
||||
// Check that no private key files are tracked by git
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["ls-files", "*.pem", "*.key"])
|
||||
.output()
|
||||
.expect("Failed to run git ls-files");
|
||||
|
||||
|
||||
let tracked_keys = String::from_utf8(output.stdout).unwrap();
|
||||
let tracked_keys: Vec<&str> = tracked_keys.lines().collect();
|
||||
|
||||
|
||||
// Only non-empty entries and only public_key.pem should be tracked
|
||||
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
|
||||
if !tracked.contains("public_key.pem") {
|
||||
assert!(false,
|
||||
"Private key file is tracked by git: {}. Remove it with git rm --cached",
|
||||
assert!(
|
||||
false,
|
||||
"Private key file is tracked by git: {}. Remove it with git rm --cached",
|
||||
tracked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
println!("PASS: No private keys tracked in git (only public_key.pem allowed)");
|
||||
}
|
||||
|
||||
@@ -116,7 +115,7 @@ fn test_no_private_key_in_git() {
|
||||
fn test_no_token_in_source_files() {
|
||||
// Scan source files for hardcoded tokens
|
||||
let mut found_issues = Vec::new();
|
||||
|
||||
|
||||
// Scan .sh files for hardcoded 40-char hex strings
|
||||
for entry in fs::read_dir(".").unwrap().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
@@ -128,18 +127,30 @@ fn test_no_token_in_source_files() {
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
// Skip comments and example/template files
|
||||
if line.trim().starts_with("#") || line.to_lowercase().contains("example") || line.to_lowercase().contains("template") {
|
||||
if line.trim().starts_with("#")
|
||||
|| line.to_lowercase().contains("example")
|
||||
|| line.to_lowercase().contains("template")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Check for 40-64 hex chars that could be API tokens (not in .env.example comments)
|
||||
if line.trim().len() >= 40 {
|
||||
let hex_chars = line.trim().chars().filter(|c| c.is_ascii_hexdigit()).collect::<Vec<_>>();
|
||||
let hex_chars = line
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<Vec<_>>();
|
||||
if hex_chars.len() >= 40 && hex_chars.len() <= 64 {
|
||||
// Check if it looks like it's part of a TOKEN assignment
|
||||
if line.to_lowercase().contains("token") || line.to_lowercase().contains("api") || line.to_lowercase().contains("secret") {
|
||||
if line.to_lowercase().contains("token")
|
||||
|| line.to_lowercase().contains("api")
|
||||
|| line.to_lowercase().contains("secret")
|
||||
{
|
||||
found_issues.push(format!(
|
||||
"Potential hardcoded token in {}: line {}: {}",
|
||||
path.display(), line_num + 1, line.trim()
|
||||
"Potential hardcoded token in {}: line {}: {}",
|
||||
path.display(),
|
||||
line_num + 1,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -148,16 +159,18 @@ fn test_no_token_in_source_files() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !found_issues.is_empty() {
|
||||
println!("FAIL: Found potential hardcoded tokens:");
|
||||
for issue in &found_issues {
|
||||
println!(" {}", issue);
|
||||
}
|
||||
assert!(false, "Found potential hardcoded tokens in shell scripts: {:?}", found_issues);
|
||||
|
||||
assert!(
|
||||
false,
|
||||
"Found potential hardcoded tokens in shell scripts: {:?}",
|
||||
found_issues
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
println!("PASS: No hardcoded tokens found in shell scripts");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
110
tests/ssrf_tests.rs
Normal file
110
tests/ssrf_tests.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use bal_server::validation::is_valid_welist_url;
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_internal_urls() {
|
||||
// Blocked: internal loopback
|
||||
assert!(
|
||||
!is_valid_welist_url("https://127.0.0.1"),
|
||||
"IPv4 loopback should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://localhost"),
|
||||
"localhost hostname should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::1]"),
|
||||
"IPv6 loopback should be blocked"
|
||||
);
|
||||
|
||||
// Blocked: private RFC1918 ranges
|
||||
assert!(
|
||||
!is_valid_welist_url("https://192.168.1.1"),
|
||||
"RFC1918 private IP should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://10.0.0.1"),
|
||||
"RFC1918 private IP should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://172.16.0.1"),
|
||||
"RFC1918 private IP should be blocked"
|
||||
);
|
||||
|
||||
// Blocked: AWS metadata link-local
|
||||
assert!(
|
||||
!is_valid_welist_url("https://169.254.169.254"),
|
||||
"AWS metadata link-local IP should be blocked"
|
||||
);
|
||||
|
||||
// Blocked: non-HTTPS schemes
|
||||
assert!(
|
||||
!is_valid_welist_url("http://welist.bitcoin-after.life"),
|
||||
"HTTP plaintext should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("ftp://welist.bitcoin-after.life"),
|
||||
"FTP scheme should be blocked"
|
||||
);
|
||||
|
||||
// Blocked: malformed URLs
|
||||
assert!(
|
||||
!is_valid_welist_url("not a url"),
|
||||
"Malformed URL should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("welist.bitcoin-after.life"),
|
||||
"URL missing scheme should be blocked"
|
||||
);
|
||||
|
||||
// Allowed: valid public domain on HTTPS
|
||||
assert!(
|
||||
is_valid_welist_url("https://welist.bitcoin-after.life"),
|
||||
"Known production domain should be allowed"
|
||||
);
|
||||
assert!(
|
||||
is_valid_welist_url("https://example.com/ping"),
|
||||
"Public domain on HTTPS should be allowed"
|
||||
);
|
||||
|
||||
// Allowed: valid public IP on HTTPS
|
||||
assert!(
|
||||
is_valid_welist_url("https://8.8.8.8"),
|
||||
"Public IP on HTTPS should be allowed"
|
||||
);
|
||||
assert!(
|
||||
is_valid_welist_url("https://1.2.3.4"),
|
||||
"Public IP on HTTPS should be allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_case_insensitive_localhost() {
|
||||
assert!(
|
||||
!is_valid_welist_url("https://LOCALHOST"),
|
||||
"Uppercase localhost should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://LocalHost"),
|
||||
"Mixed case localhost should be blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_ipv6_unique_local() {
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[fc00::1]"),
|
||||
"IPv6 unique local fc00 should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[fd00::1]"),
|
||||
"IPv6 unique local fd00 should be blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_port_presence_ok() {
|
||||
assert!(
|
||||
is_valid_welist_url("https://welist.bitcoin-after.life:443"),
|
||||
"HTTPS with explicit port should be allowed"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user