forked from bitcoinafterlife/bal-server
bal-server: - Wire independent actix-governor buckets per endpoint class (pushtxs, searchtx, info/stats, default) so polling a cheap endpoint can no longer exhaust the push-txs budget; previously every limit was dead config - Enforce BAL_SERVER_ACTIX_TIMEOUT_SECS via a request-timeout middleware that returns 408 Request Timeout, covering slow request bodies - De-duplicate txids within a single /pushtxs request body - Replace the byte-slice xpub detection (panic on non-ASCII) with a safe prefix check bal-pusher: - Handle malformed/empty ZMQ frames without indexing panics or UTF-8 expect - Return an error from sign_message and skip the stats report instead of panicking when the private key cannot be parsed validation / db: - Reject IPv4-mapped IPv6 targets (::ffff:a.b.c.d) in is_valid_welist_url to close an SSRF bypass, with unit tests - Use Path::starts_with for the SQLite forbidden-directory check so paths like /etcfoo are not misclassified misc: - Add explicit secret patterns to .gitignore and sync the docs
214 lines
7.1 KiB
Rust
214 lines
7.1 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;
|
|
}
|
|
|
|
// IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are
|
|
// routed to the embedded IPv4 address on dual-stack hosts, so
|
|
// they must be validated against the IPv4 rules as well.
|
|
if let Some(v4) = v6.to_ipv4_mapped()
|
|
&& (v4.is_loopback()
|
|
|| v4.is_private()
|
|
|| v4.is_link_local()
|
|
|| v4.is_unspecified()
|
|
|| v4.is_multicast())
|
|
{
|
|
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_ipv4_mapped_ipv6() {
|
|
assert!(
|
|
!is_valid_welist_url("https://[::ffff:127.0.0.1]"),
|
|
"IPv4-mapped loopback should be blocked"
|
|
);
|
|
assert!(
|
|
!is_valid_welist_url("https://[::ffff:10.0.0.1]"),
|
|
"IPv4-mapped private address should be blocked"
|
|
);
|
|
assert!(
|
|
!is_valid_welist_url("https://[::ffff:172.16.0.1]"),
|
|
"IPv4-mapped private address should be blocked"
|
|
);
|
|
assert!(
|
|
!is_valid_welist_url("https://[::ffff:192.168.1.1]"),
|
|
"IPv4-mapped private address should be blocked"
|
|
);
|
|
assert!(
|
|
!is_valid_welist_url("https://[::ffff:169.254.169.254]"),
|
|
"IPv4-mapped link-local address should be blocked"
|
|
);
|
|
assert!(
|
|
!is_valid_welist_url("https://[::ffff:0.0.0.0]"),
|
|
"IPv4-mapped unspecified address should be blocked"
|
|
);
|
|
assert!(
|
|
is_valid_welist_url("https://[::ffff:8.8.8.8]"),
|
|
"IPv4-mapped public address should be allowed"
|
|
);
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|