Files
bal-server/src/validation.rs
svatantrya 4fc0790fe7 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
2026-07-16 18:59:30 -04:00

169 lines
5.5 KiB
Rust

use url::Url;
/// Validates a WELIST server URL to mitigate SSRF risks.
///
/// Checks:
/// 1. URL must be well-formed and parsable.
/// 2. Scheme must be `https://` (plain HTTP is rejected).
/// 3. Host must be present.
/// 4. Host must not be `localhost` or loopback strings.
/// 5. Host must not resolve to a loopback, private, link-local, unspecified, or multicast IP address.
/// 6. IPv6 Unique Local (fc00::/7) is also rejected.
///
/// Returns `true` if the URL is safe to use, `false` otherwise.
///
/// Examples:
/// - `is_valid_welist_url("https://welist.bitcoin-after.life")` -> `true`
/// - `is_valid_welist_url("https://welist.bitcoin-after.life:443")` -> `true`
/// - `is_valid_welist_url("https://example.com/ping")` -> `true`
/// - `is_valid_welist_url("http://welist.bitcoin-after.life")` -> `false` (not HTTPS)
/// - `is_valid_welist_url("https://localhost")` -> `false` (localhost loopback)
/// - `is_valid_welist_url("https://127.0.0.1")` -> `false` (IPv4 loopback)
/// - `is_valid_welist_url("https://169.254.169.254")` -> `false` (AWS metadata link-local)
/// - `is_valid_welist_url("https://192.168.1.1")` -> `false` (IPv4 private)
/// - `is_valid_welist_url("https://10.0.0.1")` -> `false` (IPv4 private RFC1918)
pub fn is_valid_welist_url(url_str: &str) -> bool {
let url = match Url::parse(url_str) {
Ok(u) => u,
Err(_e) => return false,
};
if url.scheme() != "https" {
return false;
}
let host = match url.host_str() {
Some(h) => h.trim_start_matches('[').trim_end_matches(']'),
None => return false,
};
if host.eq_ignore_ascii_case("localhost")
|| host.eq_ignore_ascii_case("127.0.0.1")
|| host.eq_ignore_ascii_case("::1")
{
return false;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
match ip {
std::net::IpAddr::V4(v4) => {
if v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
{
return false;
}
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
|| v6.is_multicast()
|| v6.is_unique_local()
{
return false;
}
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_domains() {
assert!(is_valid_welist_url("https://welist.bitcoin-after.life"));
assert!(is_valid_welist_url("https://welist.bitcoin-after.life:443"));
assert!(is_valid_welist_url("https://example.com/ping"));
assert!(is_valid_welist_url("https://a.b.c.d.example.com"));
assert!(is_valid_welist_url("https://welist.onion.tor"));
}
#[test]
fn test_invalid_scheme() {
assert!(!is_valid_welist_url("http://welist.bitcoin-after.life"));
assert!(!is_valid_welist_url("ftp://welist.bitcoin-after.life"));
assert!(!is_valid_welist_url("https://")); // no host
}
#[test]
fn test_localhost_and_loopback() {
assert!(!is_valid_welist_url("https://localhost"));
assert!(!is_valid_welist_url("https://localhost:8080"));
assert!(
!is_valid_welist_url("https://LOCALHOST"),
"Uppercase localhost should be blocked"
);
assert!(!is_valid_welist_url("https://127.0.0.1"));
assert!(!is_valid_welist_url("https://127.0.0.1:8080"));
assert!(
!is_valid_welist_url("https://127.0.0.2"),
"Other loopback in 127/8 should be blocked"
);
assert!(
!is_valid_welist_url("https://[::1]"),
"IPv6 loopback literal should be blocked"
);
assert!(
!is_valid_welist_url("https://::1"),
"Raw IPv6 loopback without brackets should be invalid (parse fails)"
);
}
#[test]
fn test_private_ips() {
assert!(!is_valid_welist_url("https://192.168.1.1"));
assert!(!is_valid_welist_url("https://10.0.0.1"));
assert!(!is_valid_welist_url("https://172.16.0.1"));
assert!(!is_valid_welist_url("https://172.31.255.255"));
assert!(
!is_valid_welist_url("https://169.254.169.254"),
"AWS metadata link-local IP should be blocked"
);
}
#[test]
fn test_unspecified_and_multicast() {
assert!(!is_valid_welist_url("https://0.0.0.0"));
assert!(!is_valid_welist_url("https://224.0.0.1"));
}
#[test]
fn test_ipv6_link_local() {
assert!(
!is_valid_welist_url("https://[fe80::1]"),
"IPv6 link local should be blocked"
);
}
#[test]
fn test_ipv6_unique_local() {
assert!(
!is_valid_welist_url("https://[fc00::1]"),
"IPv6 unique local (fc00::/7) should be blocked"
);
assert!(
!is_valid_welist_url("https://[fd00::1]"),
"IPv6 unique local (fd00::7) should be blocked"
);
}
#[test]
fn test_malformed_urls() {
assert!(!is_valid_welist_url("not a url"));
assert!(!is_valid_welist_url("welist.bitcoin-after.life")); // missing scheme
}
#[test]
fn test_valid_public_ip() {
assert!(is_valid_welist_url("https://1.2.3.4"));
assert!(is_valid_welist_url("https://8.8.8.8"));
}
}