From 272dacadb2c03028ea0bfd58286d29d0b5cf8690 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 13:00:38 -0400 Subject: [PATCH] fix(buzz-auth): complete SSRF policy, cancellation-safe permit, and invariant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network policy (buzz-core): - Rename is_private_ip → is_not_global_unicast; is_private_ip alias preserved for unowned callers. Registry source: IANA IPv4/IPv6 Special-Purpose Address Space (registries last updated 2025-10-09, retrieved 2026-08-31; URLs in source doc comment). - Implement the IANA deny/exception table: outer predicate denies ranges whose registry entry is non-global or blank; explicit globally-reachable exceptions carved out inside otherwise-denied envelopes. IPv4 embedded in IPv4-mapped, IPv4-compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated space is evaluated recursively against the IPv4 table — registry global=True on the IPv6 wrapper does not bypass the embedded-address check. - IPv4: add 192.0.0.0/24 IETF Protocol Assignments (global=False) with globally- reachable exceptions 192.0.0.9 (PCP anycast, RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155); add 192.88.99.0/24 deprecated 6to4 relay anycast (global=None — conservative posture: block). - IPv6: replace individual Teredo/benchmarking/ORCHID checks with the 2001::/23 IETF Protocol Assignments envelope (global=False). Globally-reachable exceptions inside the /23 are allowed: 2001:1::1/2/3 (PCP/TURN/DNS-SD anycast), 2001:3::/32 (AMT), 2001:4:112::/48 (AS112-v6), 2001:20::/28 (ORCHIDv2, global=True), 2001:30::/28 (DETs, global=True). Add 100:0:0:1::/64 dummy prefix (RFC 9780), 3fff::/20 documentation (RFC 9637), 5f00::/16 SRv6 SIDs (RFC 9252). 2001:db8::/32 (outside 2001::/23) remains a separate check. - Consumer audit: buzz-workflow (CallWebhook) and desktop link_preview use the is_private_ip alias; the stricter predicate closes all new ranges for both callers. Cancellation-safe refresh permit (buzz-auth): - Per-issuer OwnedMutexGuard spans the complete fetch and state commit; cancelled callers release the permit on drop — no manual flag to poison. - ScriptedFetcher replaces BlockingFetcher + SequencedFetcher: a VecDeque of FetchStep{entered, release} makes call order self-documenting without comments. - concurrent_refresh_coalesces_without_second_fetch: entered barrier proves permit ownership before the second call; assert call_count == 1. - aborted_first_caller_releases_permit_for_next_caller: pending_step returns the release sender, which is held until after abort — task is genuinely blocked (not resolved via error path) when cancelled. assert call_count == 2. Central invariant regressions (buzz-auth): - expired_snapshot_never_served_after_hard_deadline: hard-deadline expiry closes both the async and synchronous snapshot paths. - two_issuer_keys_and_generations_are_isolated: advancing A's document advances A's generation only; B's key binding and generation are unchanged. Architecture docs (ARCHITECTURE.md): - Update is_private_ip function-table entry to is_not_global_unicast with compat alias. - Rewrite SSRF Protection section: deny/exception-table framing, embedded-IPv4 recursive evaluation, all three audited callers. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- ARCHITECTURE.md | 10 +- crates/buzz-auth/src/nip_fi/jwks/mod.rs | 44 +- crates/buzz-auth/src/nip_fi/jwks/tests.rs | 403 +++++++++++++- crates/buzz-core/src/network.rs | 638 ++++++++++++---------- 4 files changed, 766 insertions(+), 329 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 138d192fa1..4e0b0c8f1f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -361,7 +361,7 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) |----------|---------| | `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. | | `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. | -| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. | +| `is_not_global_unicast(ip)` | SSRF protection: starts from IANA deny/exception table — denies ranges whose registry entry is non-global or blank, carves out explicit global exceptions inside denied envelopes, and evaluates embedded IPv4 recursively. Registries last updated 2025-10-09. Compat alias: `is_private_ip`. | **Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime. @@ -746,12 +746,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic ### SSRF Protection -`is_private_ip()` in `buzz-core` covers: -- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255) -- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32) -- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address +`is_not_global_unicast(ip)` (compat alias `is_private_ip`) in `buzz-core` starts from the IANA deny/exception table: denies ranges whose IPv4 or IPv6 Special-Purpose Address Space registry entry is non-global or blank (registries last updated 2025-10-09), carves out explicit globally-reachable exceptions inside otherwise-denied envelopes (e.g., PCP/TURN/DNS-SD anycast inside 2001::/23), and evaluates IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96) space recursively against the IPv4 table. SIIT IPv4-translated (::ffff:0:0:0/96) follows the same recursive path. The local-use NAT64 prefix (64:ff9b:1::/48) is blocked wholesale. Conservative posture: `None`/blank entries are treated as non-global. -Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility). +Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action), +desktop `link_preview` (SSRF check). ### Audit Integrity diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index ef8b8297a7..cfa880a3c9 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -27,7 +27,7 @@ use super::config::MAX_JWKS_KEYS; use super::verifier::{AssertionKeySet, IssuerKeySource}; -use buzz_core::network::is_private_ip; +use buzz_core::network::is_not_global_unicast; use chrono::{DateTime, Duration, Utc}; use futures_util::StreamExt as _; use jsonwebtoken::jwk::JwkSet; @@ -73,12 +73,12 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { } // Reject bare private/reserved IP targets at construction time. if let Some(url::Host::Ipv4(addr)) = parsed.host() { - if is_private_ip(&std::net::IpAddr::V4(addr)) { + if is_not_global_unicast(&std::net::IpAddr::V4(addr)) { return Err(JwksFetchError::InvalidUri); } } if let Some(url::Host::Ipv6(addr)) = parsed.host() { - if is_private_ip(&std::net::IpAddr::V6(addr)) { + if is_not_global_unicast(&std::net::IpAddr::V6(addr)) { return Err(JwksFetchError::InvalidUri); } } @@ -104,7 +104,7 @@ pub(crate) async fn resolve_and_check_ssrf( ) -> Result { // Fast path: if the host is already a parsed IP literal, skip the resolver. if let Ok(ip) = host.parse::() { - if is_private_ip(&ip) { + if is_not_global_unicast(&ip) { return Err(JwksFetchError::InvalidUri); } return Ok(ip); @@ -126,7 +126,7 @@ pub(crate) async fn resolve_and_check_ssrf( return Err(JwksFetchError::NetworkError); } for ip in &addrs { - if is_private_ip(ip) { + if is_not_global_unicast(ip) { return Err(JwksFetchError::InvalidUri); } } @@ -147,8 +147,10 @@ struct IssuerState { snapshot: Option, /// Advances only when `content_digest` changes; never wraps (saturating). generation_counter: u64, - /// True while a refresh task owns the fetch lock. Prevents thundering-herd. - refresh_in_flight: bool, + /// Owned permit for in-flight refresh. Held across the complete fetch + + /// state commit; dropped automatically if the caller future is cancelled. + /// `try_lock_owned()` succeeds iff no refresh is in progress. + refresh_permit: Arc>, } impl IssuerState { @@ -156,7 +158,7 @@ impl IssuerState { Self { snapshot: None, generation_counter: 0, - refresh_in_flight: false, + refresh_permit: Arc::new(tokio::sync::Mutex::new(())), } } } @@ -230,7 +232,7 @@ pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { /// /// Per-fetch boundary enforcement: /// - hostname DNS is resolved and every address checked against -/// `buzz_core::network::is_private_ip` before the request is sent; +/// `buzz_core::network::is_not_global_unicast` before the request is sent; /// - the request is pinned to the validated address to prevent DNS rebinding /// TOCTOU (the OS resolver is called once per fetch, not once per URL); /// - the complete operation (resolution, connect, headers, body streaming) is @@ -464,9 +466,11 @@ impl ProductionJwksSource { /// Returns the cached snapshot for `issuer`, refreshing inline if stale. /// Returns `None` when no live snapshot is available and the fetch fails. /// - /// If a refresh is already in flight for this issuer, returns the current - /// snapshot rather than blocking — coalesces concurrent callers. Drops - /// both locks before the async fetch so other issuers are not blocked. + /// Coalesces concurrent callers: a second call while a refresh is in + /// flight returns the current snapshot immediately rather than starting a + /// second fetch. The refresh permit is an RAII guard — if this future is + /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and + /// the permit is released, so the next caller can start a new fetch. pub async fn get_snapshot(&self, issuer: &str) -> Option { let states = self.states.read().await; let state_mutex = states.get(issuer)?; @@ -493,11 +497,14 @@ impl ProductionJwksSource { return state.snapshot.as_ref().map(|c| c.key_set.clone()); } - if state.refresh_in_flight { - return state.snapshot.as_ref().map(|c| c.key_set.clone()); - } + // Try to acquire the per-issuer refresh permit. Failure means another + // caller is already fetching; return the current snapshot rather than + // starting a second fetch. + let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { + Ok(g) => g, + Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + }; - state.refresh_in_flight = true; let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); let prev_generation = state.generation_counter; drop(state); @@ -505,14 +512,16 @@ impl ProductionJwksSource { let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; + // Re-acquire state to commit and release the permit atomically. let states = self.states.read().await; if let Some(state_mutex) = states.get(issuer) { let mut st = state_mutex.lock().await; - st.refresh_in_flight = false; if let Some((ref cached, new_generation)) = fresh { st.generation_counter = new_generation; st.snapshot = Some(cached.clone()); } + // Drop the permit only after the state commit is visible. + drop(permit); let now2 = Utc::now(); return st .snapshot @@ -521,6 +530,7 @@ impl ProductionJwksSource { .map(|c| c.key_set.clone()); } + drop(permit); None } } diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 90bc401436..3c9403a652 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -458,7 +458,6 @@ fn validate_uri_rejects_unparseable() { #[tokio::test] async fn http_fetcher_rejects_http_uri_before_connection() { let fetcher = HttpJwksFetcher::new(); - // Would resolve DNS and return NetworkError if validation ran after I/O. let err = fetcher .fetch_jwks("http://id.example/.well-known/jwks.json") .await @@ -510,20 +509,412 @@ async fn resolve_ssrf_accepts_public_ipv6_fast_path() { assert_eq!(ip, "2606:4700::1".parse::().unwrap()); } -/// `with_deadline` must fire before the outer guard when the inner future -/// never resolves. Uses `std::future::pending()` so no DNS or I/O occurs. -/// Removing the `tokio::time::timeout` inside `with_deadline` leaves the -/// future permanently pending — the outer guard fires and the test fails. +/// `with_deadline` fires before the outer guard: removing `tokio::time::timeout` +/// inside `with_deadline` leaves the pending future unresolved and the outer guard fires. #[tokio::test(start_paused = true)] async fn with_deadline_fires_before_outer_guard() { let inner = super::with_deadline( std::future::pending::>(), std::time::Duration::ZERO, ); - // Independent 1-second outer guard. Must not be the one that fires. let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; assert_eq!( result.expect("outer guard fired — with_deadline timeout seam missing"), Err(JwksFetchError::NetworkError), ); } + +// A fetcher whose per-call behaviour is scripted by an explicit sequence of steps. +// Each call pops the next step: signals `entered` on entry, then blocks until +// its release channel resolves. +struct FetchStep { + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver, +} + +struct ScriptedFetcher { + steps: std::sync::Mutex>, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for ScriptedFetcher {} + +impl JwksFetcher for ScriptedFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + let step = self.steps.lock().unwrap().pop_front(); + async move { + match step { + Some(FetchStep { entered, release }) => { + let _ = entered.send(()); + release.await.map_err(|_| JwksFetchError::NetworkError) + } + None => Err(JwksFetchError::NetworkError), + } + } + } +} + +fn script(steps: impl IntoIterator) -> ScriptedFetcher { + ScriptedFetcher { + steps: std::sync::Mutex::new(steps.into_iter().collect()), + call_count: Arc::new(AtomicUsize::new(0)), + } +} + +fn pending_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + // release_tx is returned to the caller; the fetch future is genuinely + // pending until the caller drops or sends it — not resolved immediately. + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +fn ready_step(body: String) -> (FetchStep, tokio::sync::oneshot::Receiver<()>) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + let _ = release_tx.send(body); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + ) +} + +fn blocking_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +/// A second concurrent `get_snapshot` while the first fetch is in progress must +/// not start a second fetch — the RAII permit coalesces callers. +#[tokio::test] +async fn concurrent_refresh_coalesces_without_second_fetch() { + let (step, entered_rx, release_tx) = blocking_step(); + let fetcher = script([step]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + + entered_rx.await.unwrap(); // first fetch holds the permit + + let second_result = source.get_snapshot(issuer).await; + let count_after_second = call_count.load(Ordering::SeqCst); + + let _ = release_tx.send(minimal_jwks_json("k1")); + let first_result = first.await.unwrap(); + + assert!(first_result.is_some()); + assert!(second_result.is_none()); + assert_eq!(count_after_second, 1); +} + +/// Aborting the first caller releases the RAII permit; the next call on the same +/// source fetches and succeeds. A manual boolean cleared only on success would +/// leave the permit poisoned. +#[tokio::test] +async fn aborted_first_caller_releases_permit_for_next_caller() { + let (step1, entered_rx_1, _release_tx_1) = pending_step(); + let (step2, _entered_rx_2) = ready_step(minimal_jwks_json("k2")); + + let fetcher = script([step1, step2]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + { + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + entered_rx_1.await.unwrap(); + first.abort(); + let _ = first.await; + // _release_tx_1 drops here: the fetch future was blocked on an open + // receiver when abort fired — not resolved via an error path. + } + + let result = source.get_snapshot(issuer).await; + assert!(result.is_some()); + assert_eq!(call_count.load(Ordering::SeqCst), 2); +} + +/// An expired snapshot must never be served — both `get_snapshot` and the +/// synchronous `key_set` path return `None` after the hard deadline passes. +#[tokio::test] +async fn expired_snapshot_never_served_after_hard_deadline() { + let issuer = "https://id.example"; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 2, + }; + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Err::(JwksFetchError::NetworkError), + Ok(minimal_jwks_json("k1")), + ])); + struct FailAfterFirstFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for FailAfterFirstFetcher {} + impl JwksFetcher for FailAfterFirstFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + let source = ProductionJwksSource::new(vec![config], FailAfterFirstFetcher { bodies }).unwrap(); + + assert!( + source.get_snapshot(issuer).await.is_some(), + "initial fetch must succeed" + ); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + assert!( + source.get_snapshot(issuer).await.is_none(), + "expired snapshot must not be served after hard deadline" + ); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "key_set must not serve an expired snapshot" + ); +} + +/// Two issuers are fully isolated: distinct key material, independent generation +/// counters, no cross-issuer forgery. Three distinct P-256 keypairs (A1, A2, +/// B1) driven through `ProductionJwksSource` into `FederatedAssertionVerifier`. +#[tokio::test] +async fn two_issuer_keys_and_generations_are_isolated() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + + // Three genuinely distinct P-256 keypairs (PKCS#8 PEM + public JWK coords). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const PKCS8_B1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKcmDf3+zDWyC96/X\n\ + Gv8aYK552uF5aE6nXKzxAfl4fSWhRANCAATf0ccbp1c4mMd6WvSuliv5ZAS8iIWL\n\ + Ne2tqOfFa0hRpa41DANab1/EuDGi7PtIo8xSYwkaoib1MAJlfLvRMjQA\n\ + -----END PRIVATE KEY-----\n"; + const X_B1: &str = "39HHG6dXOJjHelr0rpYr-WQEvIiFizXtrajnxWtIUaU"; + const Y_B1: &str = "rjUMA1pvX8S4MaLs-0ijzFJjCRqiJvUwAmV8u9EyNAA"; + + const KID_A1: &str = "a-key-1"; + const KID_A2: &str = "a-key-2"; + const KID_B1: &str = "b-key-1"; + + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + fn policy(issuer: &str, aud: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![aud.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .expect("valid policy") + } + + fn configs(issuer_a: &str, issuer_b: &str) -> (IssuerJwksConfig, IssuerJwksConfig) { + ( + IssuerJwksConfig { + issuer: issuer_a.to_owned(), + jwks_uri: "https://a.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }, + IssuerJwksConfig { + issuer: issuer_b.to_owned(), + jwks_uri: "https://b.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }, + ) + } + + struct TwoFetcher { + a: std::sync::Mutex>, + b: String, + } + impl super::super::verifier::sealed::Sealed for TwoFetcher {} + impl JwksFetcher for TwoFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = if uri.contains("a.example") { + self.a + .lock() + .unwrap() + .pop_front() + .map(Ok) + .unwrap_or(Err(JwksFetchError::NetworkError)) + } else { + Ok(self.b.clone()) + }; + async move { result } + } + } + + let mut registry = IssuerRegistry::new(); + registry.insert(policy(issuer_a, audience)); + registry.insert(policy(issuer_b, audience)); + + // Pre-rotation: source serves A1 and B1. + let (cfg_a, cfg_b) = configs(issuer_a, issuer_b); + let pre = ProductionJwksSource::new( + vec![cfg_a, cfg_b], + TwoFetcher { + a: std::sync::Mutex::new([jwks_str(KID_A1, X_A1, Y_A1)].into()), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + pre.get_snapshot(issuer_a).await.unwrap(); + pre.get_snapshot(issuer_b).await.unwrap(); + + let v_pre = FederatedAssertionVerifier::new(registry.clone(), pre); + v_pre + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect("A1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A"); + + // Post-rotation: fresh source, A rotates A1→A2, B unchanged. + let (cfg_a2, cfg_b2) = configs(issuer_a, issuer_b); + let post = ProductionJwksSource::new( + vec![cfg_a2, cfg_b2], + TwoFetcher { + a: std::sync::Mutex::new( + [jwks_str(KID_A1, X_A1, Y_A1), jwks_str(KID_A2, X_A2, Y_A2)].into(), + ), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + post.get_snapshot(issuer_a).await.unwrap(); + post.get_snapshot(issuer_b).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let gen_a_pre = post.key_set(issuer_a).unwrap().generation(); + let gen_b_stable = post.key_set(issuer_b).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + post.get_snapshot(issuer_a).await.unwrap(); + + let gen_a_post = post.key_set(issuer_a).unwrap().generation(); + let gen_b_post = post.key_set(issuer_b).unwrap().generation(); + assert!( + gen_a_post > gen_a_pre, + "A generation must advance after rotation" + ); + assert_eq!( + gen_b_post, gen_b_stable, + "B generation must not advance when only A rotates" + ); + + let v_post = FederatedAssertionVerifier::new(registry, post); + v_post + .verify(&sign(PKCS8_A2, KID_A2, issuer_a, audience)) + .expect("A2 token must verify post-rotation"); + v_post + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect_err("old A1 token must fail after A2 rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A post-rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must still verify post-rotation"); +} diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c..3d03c021b7 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -19,344 +19,382 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { +/// Implementation starts from the IANA deny/exception table: the outer predicate +/// denies ranges whose registry entry is non-global or blank, then carves out +/// explicit exceptions for entries marked global inside an otherwise-denied +/// envelope (e.g., PCP/TURN/DNS-SD anycast inside 2001::/23). IPv4 embedded in +/// IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96) space is +/// evaluated recursively against the IPv4 table — registry global=True for the +/// IPv6 wrapper does not bypass the embedded-address check. SIIT IPv4-translated +/// (::ffff:0:0:0/96) follows the same recursive path. The local-use NAT64 +/// prefix (64:ff9b:1::/48) is blocked wholesale as a non-global range; its +/// embedded IPv4 payload is not decoded. +/// +/// Used for SSRF protection: outbound targets must resolve only to publicly +/// routable space. Conservative posture: `None`/blank registry entries are +/// treated as non-global. +/// +/// Registries retrieved 2026-08-31; registries last updated 2025-10-09: +/// https://www.iana.org/assignments/iana-ipv4-special-registry/ +/// https://www.iana.org/assignments/iana-ipv6-special-registry/ +/// +/// Compatibility alias: `is_private_ip` (see below). +/// +/// Callers: `buzz-auth` JWKS boundary, `buzz-workflow` webhook SSRF check, +/// desktop `link_preview` SSRF check. +pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { - let octets = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || octets[0] == 0 - || v4.is_broadcast() - // Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10 - // Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services. - || (octets[0] == 100 && (octets[1] & 0xC0) == 64) - // Benchmarking (RFC 2544) — 198.18.0.0/15 - || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + let o = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || o[0] == 0 // 0.0.0.0/8 "This network" + || v4.is_broadcast() // 255.255.255.255 + || (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 Shared/CGNAT + || (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 Benchmarking + || (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 Multicast + || (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 Reserved + // 192.0.0.0/24 IETF Protocol Assignments. + // Globally reachable exceptions: 192.0.0.9 (PCP anycast, RFC 7723) + // and 192.0.0.10 (TURN anycast, RFC 8155). + || (o[0] == 192 && o[1] == 0 && o[2] == 0 + && o[3] != 9 && o[3] != 10) + || (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1 + // 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank — conservative posture: block. + || (o[0] == 192 && o[1] == 88 && o[2] == 99) + || (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2 + || (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3 } std::net::IpAddr::V6(v6) => { - // Check IPv4-compatible and mapped addresses against IPv4 rules. + // IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules. if let Some(v4) = v6.to_ipv4() { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - let segments = v6.segments(); + let s = v6.segments(); - // NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4 - // destinations while rejecting embedded private/reserved addresses. + // NAT64 well-known prefix (RFC 6052): reachability follows the embedded + // IPv4 address (registry global=True, but SSRF policy checks payload). if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - // Legacy SIIT IPv4-translated addresses can route to the IPv4 value - // in their final four octets but are not recognized by `to_ipv4()`. + // SIIT IPv4-translated addresses (::ffff:0:0:0/96) route to the embedded + // IPv4 value and are not recognised by `to_ipv4()`. if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - v6.is_loopback() - || v6.is_unspecified() - || segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA - || segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local - || segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 - || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo - || segments[0] == 0x2002 // 2002::/16 6to4 - // RFC 3849 — documentation range, should never appear in production - || (segments[0] == 0x2001 && segments[1] == 0x0db8) + if v6.is_loopback() || v6.is_unspecified() { + return true; + } + + // 2001::/23 IETF Protocol Assignments envelope (registry global=False). + // All addresses within the /23 are non-global by default, with explicit + // globally-reachable exceptions carved out below. + // + // /23 check: segments[0]==0x2001 and top 7 bits of segments[1] are zero + // (i.e., segments[1] in [0x0000..0x01ff]). + if s[0] == 0x2001 && (s[1] >> 9) == 0 { + // Globally reachable exceptions inside 2001::/23 (registry global=True): + // 2001:1::1 PCP Anycast RFC 7723 + // 2001:1::2 TURN Anycast RFC 8155 + // 2001:1::3 DNS-SD SRP Anycast RFC 9665 + // 2001:3::/32 AMT RFC 7450 + // 2001:4:112::/48 AS112-v6 RFC 7535 + // 2001:20::/28 ORCHIDv2 RFC 7343 (segments[1] in 0x0020..0x002f) + // 2001:30::/28 DETs Prefix RFC 9374 (segments[1] in 0x0030..0x003f) + let is_global_exception = (s[1] == 1 + && s[2] == 0 + && s[3] == 0 + && s[4] == 0 + && s[5] == 0 + && s[6] == 0 + && matches!(s[7], 1..=3)) + || s[1] == 3 // 2001:3::/32 AMT + || (s[1] == 4 && s[2] == 0x0112) // 2001:4:112::/48 AS112-v6 + || (s[1] >> 4) == 0x0002 // 2001:20::/28 ORCHIDv2 + || (s[1] >> 4) == 0x0003; // 2001:30::/28 DETs + + if !is_global_exception { + return true; + } + } + + s[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA + || s[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local + || s[0] & 0xff00 == 0xff00 // ff00::/8 multicast + // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) + || (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 1) + // 100::/64 Discard-Only (RFC 6666) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) + // 100:0:0:1::/64 Dummy IPv6 Prefix (RFC 9780) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1) + // 2001:db8::/32 Documentation (RFC 3849) — outside 2001::/23 + || (s[0] == 0x2001 && s[1] == 0x0db8) + || s[0] == 0x2002 // 2002::/16 6to4 (RFC 3056) + // 3fff::/20 Documentation (RFC 9637) + || (s[0] == 0x3fff && (s[1] >> 12) == 0) + || s[0] == 0x5f00 // 5f00::/16 SRv6 SIDs (RFC 9252) } } } +/// Compatibility alias; prefer [`is_not_global_unicast`]. +#[inline] +pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { + is_not_global_unicast(ip) +} + #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; - #[test] - fn test_loopback_v4() { - assert!(is_private_ip(&"127.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_10() { - assert!(is_private_ip(&"10.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_172() { - assert!(is_private_ip(&"172.16.0.1".parse::().unwrap())); - } - #[test] - fn test_private_192() { - assert!(is_private_ip(&"192.168.1.1".parse::().unwrap())); - } - #[test] - fn test_link_local() { - assert!(is_private_ip(&"169.254.1.1".parse::().unwrap())); - } - #[test] - fn test_unspecified() { - assert!(is_private_ip(&"0.0.0.0".parse::().unwrap())); - } - #[test] - fn test_broadcast() { - assert!(is_private_ip(&"255.255.255.255".parse::().unwrap())); - } - #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); - } - #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); - } - #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); - } - #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); - } - #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); - } - #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); - } - #[test] - fn test_documentation_range_v6() { - // 2001:db8::/32 — RFC 3849 documentation range, must be blocked - assert!(is_private_ip(&"2001:db8::1".parse::().unwrap())); - assert!(is_private_ip( - &"2001:db8:ffff::1".parse::().unwrap() - )); - } - #[test] - fn test_ipv4_mapped_v6_private() { - // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4 - assert!(is_private_ip(&"::ffff:10.0.0.1".parse::().unwrap())); - } - #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); - } - #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); - } - #[test] - fn test_ipv4_compatible_v6_private() { - assert!(is_private_ip(&"::10.0.0.1".parse::().unwrap())); - assert!(is_private_ip(&"::127.0.0.1".parse::().unwrap())); - assert!(is_private_ip( - &"::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip(&"::8.8.8.8".parse::().unwrap())); - } - #[test] - fn test_nat64_well_known_prefix() { - let first = "64:ff9b::".parse().unwrap(); - let last = "64:ff9b::ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - let embedded = "64:ff9b::172.16.1.2".parse().unwrap(); - assert_eq!( - embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX), - Some("172.16.1.2".parse().unwrap()) - ); - assert!(is_private_ip( - &"64:ff9b::10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::().unwrap())); - } - #[test] - fn test_ipv4_translated_prefix() { - let first = "0:0:0:0:ffff:0:0:0".parse().unwrap(); - let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - assert!(is_private_ip( - &"::ffff:0:10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"::ffff:0:8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:fffe:ffff:ffff:ffff".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:ffff:1:0:0".parse::().unwrap() - )); - } - #[test] - fn test_nat64_local_use_prefix_boundaries() { - assert!(is_private_ip(&"64:ff9b:1::".parse::().unwrap())); - assert!(is_private_ip( - &"64:ff9b:1:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b:2::".parse::().unwrap())); - } - #[test] - fn test_teredo_prefix_boundaries() { - assert!(is_private_ip(&"2001::".parse::().unwrap())); - assert!(is_private_ip( - &"2001:0:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2001:1::1".parse::().unwrap())); - } - #[test] - fn test_6to4_prefix_boundaries() { - assert!(is_private_ip(&"2002::".parse::().unwrap())); - assert!(is_private_ip( - &"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2003::1".parse::().unwrap())); + fn blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } - // CGNAT (RFC 6598) — 100.64.0.0/10 #[test] - fn test_cgnat_start() { - // 100.64.0.1 — start of CGNAT range - assert!(is_private_ip(&"100.64.0.1".parse::().unwrap())); - } - #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); - } - #[test] - fn test_cgnat_below_range() { - // 100.63.255.255 — just below CGNAT range (100.0–100.63 is public) - assert!(!is_private_ip(&"100.63.255.255".parse::().unwrap())); - } - #[test] - fn test_cgnat_above_range() { - // 100.128.0.0 — just above CGNAT range (100.128+ is public) - assert!(!is_private_ip(&"100.128.0.0".parse::().unwrap())); + fn public_v4() { + assert!(!blocked("1.1.1.1")); + assert!(!blocked("8.8.8.8")); } - // Benchmarking (RFC 2544) — 198.18.0.0/15 #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); - } - #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); - } - #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); - } - #[test] - fn test_benchmarking_above_range() { - // 198.20.0.0 — just above benchmarking range - assert!(!is_private_ip(&"198.20.0.0".parse::().unwrap())); + fn public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } - // IPv6 multicast — ff00::/8 #[test] - fn test_ipv6_multicast_all_nodes() { - // ff02::1 — all-nodes multicast - assert!(is_private_ip(&"ff02::1".parse::().unwrap())); + fn loopback_and_unspecified() { + assert!(blocked("127.0.0.1")); + assert!(blocked("0.0.0.0")); + assert!(blocked("::1")); + assert!(blocked("::")); } + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".parse::().unwrap())); + fn private_rfc1918() { + assert!(blocked("10.0.0.1")); + assert!(blocked("172.16.0.1")); + assert!(blocked("192.168.1.1")); } + #[test] - fn test_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn link_local() { + assert!(blocked("169.254.1.1")); + assert!(blocked("fe80::1")); } + #[test] - fn test_ipv6_not_multicast() { - // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + fn broadcast() { + assert!(blocked("255.255.255.255")); + } + + #[test] + fn cgnat() { + assert!(blocked("100.64.0.1")); + assert!(blocked("100.127.255.254")); + assert!(!blocked("100.63.255.255")); + assert!(!blocked("100.128.0.0")); + } + + #[test] + fn benchmarking_v4() { + assert!(blocked("198.18.0.1")); + assert!(blocked("198.19.255.254")); + assert!(!blocked("198.17.255.255")); + assert!(!blocked("198.20.0.0")); + } + + #[test] + fn multicast_and_reserved_v4() { + assert!(blocked("224.0.0.0")); + assert!(blocked("239.255.255.255")); + assert!(blocked("240.0.0.0")); + assert!(blocked("254.255.255.255")); + assert!(!blocked("223.255.255.255")); + } + + // Most of 192.0.0.0/24 is non-global; 192.0.0.9 (PCP, RFC 7723) and + // 192.0.0.10 (TURN, RFC 8155) are the only globally-reachable exceptions. + #[test] + fn ietf_protocol_assignments() { + assert!(blocked("192.0.0.0")); + assert!(blocked("192.0.0.1")); + assert!(blocked("192.0.0.170")); // NAT64/DNS64 discovery — non-global + assert!(blocked("192.0.0.255")); + assert!(!blocked("192.0.0.9")); // PCP Anycast (RFC 7723) — global + assert!(!blocked("192.0.0.10")); // TURN Anycast (RFC 8155) — global + } + + #[test] + fn documentation_v4() { + assert!(blocked("192.0.2.0")); + assert!(blocked("192.0.2.255")); + assert!(blocked("198.51.100.0")); + assert!(blocked("198.51.100.255")); + assert!(blocked("203.0.113.0")); + assert!(blocked("203.0.113.255")); + assert!(!blocked("192.0.1.255")); + assert!(!blocked("192.0.3.0")); + assert!(!blocked("198.51.99.255")); + assert!(!blocked("198.51.101.0")); + assert!(!blocked("203.0.112.255")); + assert!(!blocked("203.0.114.0")); + } + + // Registry global field is None/blank; conservative posture: block. + #[test] + fn deprecated_6to4_anycast_v4() { + assert!(blocked("192.88.99.0")); + assert!(blocked("192.88.99.1")); + assert!(blocked("192.88.99.255")); + assert!(!blocked("192.88.98.255")); + assert!(!blocked("192.88.100.0")); + } + + #[test] + fn ula_v6() { + assert!(blocked("fd00::1")); + assert!(blocked("fc00::1")); + } + + #[test] + fn multicast_v6() { + assert!(blocked("ff02::1")); + assert!(blocked("ff02::2")); + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); + } + + #[test] + fn ietf_protocol_assignments_v6_interior() { + assert!(blocked("2001::")); + assert!(blocked("2001:2::1")); + assert!(blocked("2001:10::1")); + assert!(blocked("2001:db8::1")); // Documentation — outside /23 but blocked separately + assert!(blocked("2001:1ff:ffff::1")); + assert!(!blocked("2001:200::1")); + } + + #[test] + fn ietf_protocol_assignments_v6_global_exceptions() { + // PCP/TURN/DNS-SD anycast /128s — registry global=True + assert!(!blocked("2001:1::1")); // PCP Anycast (RFC 7723) + assert!(!blocked("2001:1::2")); // TURN Anycast (RFC 8155) + assert!(!blocked("2001:1::3")); // DNS-SD SRP Anycast (RFC 9665) + assert!(blocked("2001:1::4")); // not an exception + assert!(blocked("2001:1:1::1")); // not an exception + + // 2001:3::/32 AMT — registry global=True + assert!(!blocked("2001:3::1")); + assert!(!blocked("2001:3:ffff::1")); + assert!(blocked("2001:4::1")); + + // 2001:4:112::/48 AS112-v6 — registry global=True + assert!(!blocked("2001:4:112::1")); + assert!(!blocked("2001:4:112:ffff::1")); + assert!(blocked("2001:4:113::1")); + + // 2001:20::/28 ORCHIDv2 — registry global=True + assert!(!blocked("2001:20::1")); + assert!(!blocked("2001:2f::1")); + assert!(blocked("2001:10::1")); + + // 2001:30::/28 DETs — registry global=True + assert!(!blocked("2001:30::1")); + assert!(!blocked("2001:3f::1")); + assert!(!blocked("2001:3::1")); // AMT exception — distinct check + } + + #[test] + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); + } + + #[test] + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); + } + + #[test] + fn discard_only_v6() { + assert!(blocked("100::1")); + assert!(blocked("100::ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:1::1")); // outside both discard and dummy ranges + } + + #[test] + fn dummy_prefix_v6() { + assert!(blocked("100:0:0:1::")); + assert!(blocked("100:0:0:1:ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:0:2::1")); + } + + #[test] + fn nat64_local_use_v6() { + assert!(blocked("64:ff9b:1::")); + assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("64:ff9b:2::")); + } + + #[test] + fn documentation_3fff_v6() { + assert!(blocked("3fff::1")); + assert!(blocked("3fff:0fff::1")); + assert!(!blocked("3fff:1000::1")); + assert!(!blocked("3ffe::1")); + } + + #[test] + fn srv6_sids_v6() { + assert!(blocked("5f00::1")); + assert!(blocked("5f00:ffff::1")); + assert!(!blocked("5e00::1")); + assert!(!blocked("5fff::1")); // 5fff ≠ 5f00 — outside /16 + } + + #[test] + fn nat64_well_known_v6() { + assert!(blocked("64:ff9b::10.0.0.1")); // private embedded + assert!(blocked("64:ff9b::127.0.0.1")); // loopback embedded + assert!(blocked("64:ff9b::169.254.169.254")); // link-local embedded + assert!(!blocked("64:ff9b::8.8.8.8")); // public embedded — policy follows payload + assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix + assert!(!blocked("64:ff9b::1:0:0")); // outside /96 + } + + #[test] + fn ipv4_translated_v6() { + assert!(blocked("::ffff:0:10.0.0.1")); + assert!(blocked("::ffff:0:127.0.0.1")); + assert!(!blocked("::ffff:0:8.8.8.8")); + assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix + } + + #[test] + fn ipv4_mapped_v6() { + assert!(blocked("::ffff:10.0.0.1")); + assert!(blocked("::ffff:127.0.0.1")); + assert!(!blocked("::ffff:8.8.8.8")); + } + + #[test] + fn ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(!blocked("::8.8.8.8")); } }