From b8d87ecabd9443c775ad3a602ab42e0bd4e70533 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Wed, 14 Oct 2026 18:30:23 -0400 Subject: [PATCH] fix: per-endpoint rate limits, request timeout and input hardening 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 --- .gitignore | 8 +++ Cargo.toml | 2 +- docs/07_deployment_and_ops.md | 15 ++-- docs/08_security_audit.md | 33 +++++---- src/bin/bal-pusher.rs | 28 +++++--- src/bin/bal-server.rs | 132 ++++++++++++++++++++++++++++------ src/db/mod.rs | 16 ++++- src/validation.rs | 45 ++++++++++++ 8 files changed, 224 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 3092ec8..a476f4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,18 @@ # Secrets and environment files - NEVER commit .env .env.* +.env.local +*.env.local +.env.production +.env.secret !.env.example *.pem +private_key.pem +privkey.pem !public_key.pem *.key +ec.key +chiave_privata.key # Databases *.db diff --git a/Cargo.toml b/Cargo.toml index a727d24..17bc527 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ serde = { version = "1.0.152", features = ["derive"] } serde_json = { version = "1.0.116" } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] } regex = { version = "1.10.4" } -tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] } +tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread","time"] } url = { version = "2" } # server-only diff --git a/docs/07_deployment_and_ops.md b/docs/07_deployment_and_ops.md index 6c410a3..534ad03 100644 --- a/docs/07_deployment_and_ops.md +++ b/docs/07_deployment_and_ops.md @@ -41,15 +41,20 @@ Example: `BAL_SERVER_REGTEST_ADDRESS=tpub...`, `BAL_SERVER_BITCOIN_FIXED_FEE=500 | `BAL_SERVER_ACTIX_WORKERS` | `4` | Number of actix-web worker threads | | `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | `100` | Maximum concurrent connections | | `BAL_SERVER_TRUSTED_PROXY` | `127.0.0.1` | Trusted reverse proxy IP for rate-limiting client identification | -| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: requests per second (applied to all endpoints) | -| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: burst size (applied to all endpoints) | +| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: push txs requests per second | +| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: push txs burst size | | `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | `5` | Rate limit: searchtx requests per second | | `BAL_SERVER_ACTIX_SEARCHTX_BURST` | `10` | Rate limit: searchtx burst size | -| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info requests per second | -| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info burst size | -| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second | +| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info/stats requests per second | +| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info/stats burst size | +| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second (`/`, `/.pub_key.pem`, `/version`) | | `BAL_SERVER_ACTIX_DEFAULT_BURST` | `100` | Rate limit: default burst size | +Each endpoint class has its own independent token bucket, keyed by the real +client IP (see `BAL_SERVER_TRUSTED_PROXY`). `BAL_SERVER_ACTIX_TIMEOUT_SECS` is +enforced by a request-timeout middleware that returns `408 Request Timeout` if +the handler (including request body reading) exceeds the limit. + ### `bal-pusher` (prefixed `BAL_PUSHER_`) #### Core Settings diff --git a/docs/08_security_audit.md b/docs/08_security_audit.md index c8156fd..ffd4fb1 100644 --- a/docs/08_security_audit.md +++ b/docs/08_security_audit.md @@ -36,7 +36,7 @@ **Description:** All `unwrap()`/`expect()` calls on critical paths have been replaced with safe error handling. **Mitigation Applied:** - `bal-server`: `from_utf8` returns 400, `sqlite::open` uses `open_db()` with validation, all handlers return proper HTTP status codes. -- `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, `fs::read_to_string` uses `match` + 500, `cfg.lock()` uses `poisoned.into_inner()` recovery. +- `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, malformed ZMQ frames are handled with `get()`/`first()` and `from_utf8_lossy` (no indexing or UTF-8 `expect`), stats signing returns an error that is logged and skipped rather than `expect`-panicking. **Regression Tests:** `tests/panic_regression_tests.rs` (2 tests). ### 3. Secret Leakage (FIXED) @@ -52,18 +52,20 @@ **Description:** All DoS vectors mitigated via actix-web migration. **Mitigation Applied:** - Body size limit: `PayloadConfig::default().limit(max_body_size)` via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB). -- Rate limiting: `actix-governor` with token-bucket per client IP (`BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST`). Uses `RealIpKeyExtractor` to extract real client IP from proxy headers. +- Rate limiting: `actix-governor` with a token bucket per client IP, with an **independent bucket for each endpoint class** (push txs, searchtx, info/stats, default) so that polling a cheap endpoint cannot exhaust the push-txs budget. Uses `RealIpKeyExtractor` to extract the real client IP from proxy headers. - Connection limits: `workers(4)` and `max_connections(100)` via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`. -- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS`. +- Request timeout: `middleware::from_fn` wraps every handler in `tokio::time::timeout`, returning `408 Request Timeout` when `BAL_SERVER_ACTIX_TIMEOUT_SECS` (default 5) elapses, covering slow request bodies. - ZMQ timeout: `set_rcvtimeo(5000)` prevents infinite blocking. - RPC retry: sleep + retry on connection failure instead of panic. +- ZMQ message handling is panic-free: multipart frames are accessed with `get()`/`first()` and topics are decoded with `from_utf8_lossy`, so malformed frames cannot abort the pusher. +- Stats-report signing failures are logged and skipped instead of panicking the pusher when the key is unreadable. ### 5. SSRF / Network Abuse via `reqwest` (FIXED) **Severity:** MEDIUM | **Status:** Fixed **Location:** `src/bin/bal-pusher.rs`, `src/validation.rs` **Description:** URL validation prevents redirecting requests to internal/private IPs. -**Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, and IPv6 unique-local addresses. HTTPS-only scheme enforced. -**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 8 unit tests in `src/validation.rs`. +**Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, IPv6 unique-local addresses, and **IPv4-mapped IPv6 addresses** (`::ffff:a.b.c.d`) that route to loopback/private/link-local/unspecified IPv4 targets. HTTPS-only scheme enforced. Note: hostnames are not DNS-resolved before validation, so a domain that resolves to a private address is not caught by this check — restrict `WELIST_SERVER_URL` to trusted values. +**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 11 unit tests in `src/validation.rs`. ### 6. Insecure Database Access (FIXED) **Severity:** MEDIUM | **Status:** Fixed @@ -130,16 +132,17 @@ 6. Use read-only filesystem for the server binary. ### Application-Level -1. **Rate Limiting:** Implemented via `actix-governor` with token-bucket per client IP. `RealIpKeyExtractor` identifies clients behind reverse proxy using `X-Real-IP` / `X-Forwarded-For` headers. Trusted proxy IP configurable via `BAL_SERVER_TRUSTED_PROXY`. +1. **Rate Limiting:** Implemented via `actix-governor` with token-bucket per client IP and a **separate bucket per endpoint class**. `RealIpKeyExtractor` identifies clients behind reverse proxy using `X-Real-IP` / `X-Forwarded-For` headers. Trusted proxy IP configurable via `BAL_SERVER_TRUSTED_PROXY`. 2. **Input Validation:** Network enum check, txid hex validation, body size limits. -3. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt. -4. **WAL Mode:** Enabled with retry logic for concurrent access. -5. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking. -6. **Transaction Size Limits:** Configurable via rate limiting and body size. -7. **ZMQ Retry:** Reconnect logic with timeout-based detection. -8. **Fee Limits:** Per-network `fixed_fee` configuration. -9. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest). -10. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction. +3. **Request Timeout:** `tokio::time::timeout` middleware returns `408` when a handler (including body read) exceeds `BAL_SERVER_ACTIX_TIMEOUT_SECS`. +4. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt. +5. **WAL Mode:** Enabled with retry logic for concurrent access. +6. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking. +7. **Transaction Size Limits:** Configurable via rate limiting and body size. +8. **ZMQ Retry:** Reconnect logic with timeout-based detection. +9. **Fee Limits:** Per-network `fixed_fee` configuration. +10. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest). +11. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction. --- @@ -153,4 +156,4 @@ | `tests/secret_leakage_tests.rs` | 3 | .gitignore, no tracked secrets, no hardcoded tokens | | `tests/input_validation_tests.rs` | 4 | Address caching, network validation, txid hex validation | | `tests/db_path_validation.rs` | 5 | Path traversal, forbidden dirs, symlinks, WAL pragma, valid paths | -| `src/validation.rs` (inline) | 8 | SSRF URL validation unit tests | +| `src/validation.rs` (inline) | 11 | SSRF URL validation unit tests | diff --git a/src/bin/bal-pusher.rs b/src/bin/bal-pusher.rs index 55c63eb..9b0bb6a 100644 --- a/src/bin/bal-pusher.rs +++ b/src/bin/bal-pusher.rs @@ -388,7 +388,13 @@ async fn send_stats_report( cfg.url, chain, bcinfo.blocks, bcinfo.median_time, bcinfo.best_block_hash ); trace!("message to be sent: {}", message); - let sign = sign_message(cfg.ssl_key_path.as_str(), message.as_str()); + let sign = match sign_message(cfg.ssl_key_path.as_str(), message.as_str()) { + Ok(signature) => signature, + Err(e) => { + error!("Cannot sign stats report, skipping: {}", e); + return Ok(()); + } + }; let response = client .post(url) .header("User-Agent", format!("bal-pusher/{}", VERSION)) @@ -414,12 +420,12 @@ async fn send_stats_report( } Ok(()) } -fn sign_message(private_key_path: &str, message: &str) -> String { - let signing_key = - SigningKey::read_pkcs8_pem_file(private_key_path).expect("failed to parse private key PEM"); +fn sign_message(private_key_path: &str, message: &str) -> Result> { + let signing_key = SigningKey::read_pkcs8_pem_file(private_key_path) + .map_err(|e| format!("failed to parse private key PEM at {private_key_path}: {e}"))?; let signature = signing_key.sign(message.as_bytes()); - general_purpose::STANDARD.encode(signature.to_bytes()) + Ok(general_purpose::STANDARD.encode(signature.to_bytes())) } fn parse_env(cfg: &mut MyConfig) { @@ -561,12 +567,12 @@ async fn main() -> std::io::Result<()> { ); } consecutive_timeouts = 0; - let topic = message[0].clone(); - let body = message[1].clone(); - debug!( - "ZMQ:GET TOPIC: {}", - String::from_utf8(topic.clone()).expect("invalid topic") - ); + let Some(topic) = message.first().cloned() else { + warn!("ZMQ: received empty multipart message, skipping"); + continue; + }; + let body = message.get(1).cloned().unwrap_or_default(); + debug!("ZMQ:GET TOPIC: {}", String::from_utf8_lossy(&topic)); trace!("ZMQ:GET BODY: {}", hex::encode(&body)); if topic == b"hashblock" { info!("NEW BLOCK: {}", hex::encode(&body)); diff --git a/src/bin/bal-server.rs b/src/bin/bal-server.rs index 5884193..1dc2ff1 100644 --- a/src/bin/bal-server.rs +++ b/src/bin/bal-server.rs @@ -1,8 +1,11 @@ -use actix_governor::{Governor, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError}; -use actix_web::dev::ServiceRequest; -use actix_web::middleware; +use actix_governor::{ + Governor, GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError, +}; +use actix_web::body::MessageBody; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::middleware::{self, Next}; use actix_web::web::Bytes; -use actix_web::{App, HttpResponse, HttpServer, Responder, web}; +use actix_web::{App, Error, HttpResponse, HttpServer, Responder, web}; use bitcoin::{Network, Transaction, consensus}; use chrono::Utc; use hex_conservative::FromHex; @@ -13,6 +16,7 @@ use std::env; use std::fs; use std::net::IpAddr; use std::str::FromStr; +use std::time::Duration; use bal_server::db::{ DatabasePool, InsertInpData, InsertOutData, InsertTxData, check_duplicate_txids, @@ -123,7 +127,6 @@ pub struct StatsResponse { } #[derive(Debug, Clone)] -#[expect(dead_code)] struct ActixConfig { max_body_size: usize, timeout_secs: u64, @@ -287,6 +290,44 @@ impl KeyExtractor for RealIpKeyExtractor { } } +/// Concrete governor configuration type used by all rate-limited routes. +/// +/// `NoOpMiddleware` is the default `actix-governor` middleware; it is only used +/// here as a type parameter so a single helper can build every route's config. +type GovernorConfigType = + GovernorConfig; + +/// Build a rate-limiter configuration keyed by the real client IP. +/// +/// A period of zero is rejected by `actix-governor`, so the values are clamped +/// to a minimum of one to avoid a `None` result. +fn build_governor_config(per_sec: u64, burst: u32) -> Result { + GovernorConfigBuilder::default() + .seconds_per_request(per_sec.max(1)) + .burst_size(burst.max(1)) + .key_extractor(RealIpKeyExtractor) + .finish() + .ok_or_else(|| "invalid rate limiter configuration".to_string()) +} + +/// Middleware that aborts a request with `408 Request Timeout` if the inner +/// handler (including request body reading) exceeds the configured duration. +/// +/// The duration is read from the `web::Data` app data registered in `main`. +async fn request_timeout( + timeout_secs: web::Data, + req: ServiceRequest, + next: Next, +) -> Result, Error> +where + B: MessageBody, +{ + match tokio::time::timeout(Duration::from_secs(**timeout_secs), next.call(req)).await { + Ok(result) => result, + Err(_) => Err(actix_web::error::ErrorRequestTimeout("request timeout")), + } +} + async fn echo_info( path: web::Path, data: web::Data, @@ -464,6 +505,7 @@ fn parse_request_transactions( known_addresses: &HashSet, ) -> Vec<(ParsedTx, String, u64)> { let mut result: Vec<(ParsedTx, String, u64)> = Vec::new(); + let mut seen_txids: HashSet = HashSet::new(); for line in strbody.split('\n') { if line.is_empty() { @@ -489,6 +531,10 @@ fn parse_request_transactions( }; let txid = tx.compute_txid().to_string(); + if !seen_txids.insert(txid.clone()) { + trace!("duplicate txid {} in request body, skipping", txid); + continue; + } let ntxid = tx.compute_ntxid(); let wtxid = tx.compute_wtxid(); let locktime = tx.lock_time.to_string(); @@ -750,7 +796,10 @@ fn parse_env_netconfig(cfg: &mut MyConfig, chain: &str) { if let Ok(value) = env::var(format!("BAL_SERVER_{}_ADDRESS", chain.to_uppercase())) { debug!("BAL_SERVER_{}_ADDRESS: {}", chain.to_uppercase(), value); c.address = value; - if c.address.len() > 5 && &c.address[1..4] == "pub" { + let is_xpub = ["xpub", "ypub", "zpub", "tpub", "vpub", "upub"] + .iter() + .any(|prefix| c.address.starts_with(prefix)); + if is_xpub { c.xpub = true; trace!("is_xpub"); } @@ -813,12 +862,26 @@ async fn main() -> std::io::Result<()> { let bind_address = data.cfg.bind_address.clone(); let bind_port = data.cfg.bind_port; - let governor_conf = GovernorConfigBuilder::default() - .seconds_per_request(actix_cfg.rate_limit_pushtxs.0) - .burst_size(actix_cfg.rate_limit_pushtxs.1) - .key_extractor(RealIpKeyExtractor) - .finish() - .unwrap(); + // One shared limiter per endpoint class: all workers clone the same + // underlying state, so the rate limit is global per real client IP. + let governor_pushtxs = build_governor_config( + actix_cfg.rate_limit_pushtxs.0, + actix_cfg.rate_limit_pushtxs.1, + ) + .map_err(std::io::Error::other)?; + let governor_searchtx = build_governor_config( + actix_cfg.rate_limit_searchtx.0, + actix_cfg.rate_limit_searchtx.1, + ) + .map_err(std::io::Error::other)?; + let governor_info = + build_governor_config(actix_cfg.rate_limit_info.0, actix_cfg.rate_limit_info.1) + .map_err(std::io::Error::other)?; + let governor_default = build_governor_config( + actix_cfg.rate_limit_default.0, + actix_cfg.rate_limit_default.1, + ) + .map_err(std::io::Error::other)?; println!("Starting server on http://{}:{}", bind_address, bind_port); @@ -827,16 +890,45 @@ async fn main() -> std::io::Result<()> { .app_data(web::PayloadConfig::default().limit(actix_cfg.max_body_size)) .app_data(data.clone()) .app_data(web::Data::new(actix_cfg.trusted_proxy)) + .app_data(web::Data::new(actix_cfg.timeout_secs)) .wrap(middleware::Logger::default()) .wrap(middleware::Compress::default()) - .wrap(Governor::new(&governor_conf)) - .service(web::resource("/").route(web::get().to(echo_home))) - .service(web::resource("/.pub_key.pem").route(web::get().to(echo_pub_key))) - .service(web::resource("/version").route(web::get().to(echo_version))) - .service(web::resource("/{network}/info").route(web::get().to(echo_info))) - .service(web::resource("/{network}/stats").route(web::get().to(echo_stats))) - .service(web::resource("/{network}/pushtxs").route(web::post().to(echo_push))) - .service(web::resource("/searchtx").route(web::post().to(echo_search))) + .wrap(middleware::from_fn(request_timeout)) + .service( + web::resource("/") + .wrap(Governor::new(&governor_default)) + .route(web::get().to(echo_home)), + ) + .service( + web::resource("/.pub_key.pem") + .wrap(Governor::new(&governor_default)) + .route(web::get().to(echo_pub_key)), + ) + .service( + web::resource("/version") + .wrap(Governor::new(&governor_default)) + .route(web::get().to(echo_version)), + ) + .service( + web::resource("/{network}/info") + .wrap(Governor::new(&governor_info)) + .route(web::get().to(echo_info)), + ) + .service( + web::resource("/{network}/stats") + .wrap(Governor::new(&governor_info)) + .route(web::get().to(echo_stats)), + ) + .service( + web::resource("/{network}/pushtxs") + .wrap(Governor::new(&governor_pushtxs)) + .route(web::post().to(echo_push)), + ) + .service( + web::resource("/searchtx") + .wrap(Governor::new(&governor_searchtx)) + .route(web::post().to(echo_search)), + ) }) .workers(actix_cfg.workers) .max_connections(actix_cfg.max_connections) diff --git a/src/db/mod.rs b/src/db/mod.rs index a6591b8..7287174 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -38,13 +38,23 @@ fn validate_sqlite_path(dsn: &str) -> Result<(), String> { // If absolute, block known sensitive system directories if p.is_absolute() { let forbidden = [ - "/etc", "/proc", "/sys", "/dev", "/usr", "/bin", "/sbin", "/lib", "/opt", + Path::new("/etc"), + Path::new("/proc"), + Path::new("/sys"), + Path::new("/dev"), + Path::new("/usr"), + Path::new("/bin"), + Path::new("/sbin"), + Path::new("/lib"), + Path::new("/opt"), ]; for prefix in &forbidden { - if path_str.starts_with(prefix) { + // `Path::starts_with` matches whole path components, so "/etcfoo" + // is not treated as being under "/etc". + if p.starts_with(prefix) { return Err(format!( "Absolute database path under {} is forbidden", - prefix + prefix.display() )); } } diff --git a/src/validation.rs b/src/validation.rs index 4d19231..62f7156 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -65,6 +65,19 @@ pub fn is_valid_welist_url(url_str: &str) -> bool { { 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; + } } } } @@ -154,6 +167,38 @@ mod tests { ); } + #[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"));