fix(buzz-auth): complete SSRF policy, cancellation-safe permit, and invariant tests

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-31 13:00:38 -04:00
parent 8194e62929
commit 272dacadb2
4 changed files with 766 additions and 329 deletions

View File

@@ -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

View File

@@ -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<std::net::IpAddr, JwksFetchError> {
// Fast path: if the host is already a parsed IP literal, skip the resolver.
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
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<CachedSnapshot>,
/// 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<tokio::sync::Mutex<()>>,
}
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<F: JwksFetcher> ProductionJwksSource<F> {
/// 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<AssertionKeySet> {
let states = self.states.read().await;
let state_mutex = states.get(issuer)?;
@@ -493,11 +497,14 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
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<F: JwksFetcher> ProductionJwksSource<F> {
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<F: JwksFetcher> ProductionJwksSource<F> {
.map(|c| c.key_set.clone());
}
drop(permit);
None
}
}

View File

@@ -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::<std::net::IpAddr>().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::<Result<String, JwksFetchError>>(),
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<String>,
}
struct ScriptedFetcher {
steps: std::sync::Mutex<std::collections::VecDeque<FetchStep>>,
call_count: Arc<AtomicUsize>,
}
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<Output = Result<String, JwksFetchError>> + 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<Item = FetchStep>) -> 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<String>,
) {
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<String>();
// 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::<String>();
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<String>,
) {
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<String>();
(
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::<String, JwksFetchError>(JwksFetchError::NetworkError),
Ok(minimal_jwks_json("k1")),
]));
struct FailAfterFirstFetcher {
bodies: Arc<std::sync::Mutex<Vec<Result<String, JwksFetchError>>>>,
}
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<Output = Result<String, JwksFetchError>> + 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<std::collections::VecDeque<String>>,
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<Output = Result<String, JwksFetchError>> + 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");
}

View File

@@ -19,344 +19,382 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option<std::net:
.then(|| std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]))
}
/// Returns `true` if the IP address is in a private, reserved, or
/// loopback range. Used for SSRF protection — webhook targets must
/// not resolve to these addresses.
/// Returns `true` when the address is **not** a globally reachable public unicast
/// address — i.e., it falls within any range whose `Globally Reachable` column in
/// the IANA IPv4 or IPv6 Special-Purpose Address Space registry (registries last
/// updated 2025-10-09) is `False`, `None`, or absent, or within multicast space.
///
/// Blocked ranges:
/// - IPv4 loopback 127.0.0.0/8
/// - IPv4 private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
/// - IPv4 link-local 169.254.0.0/16
/// - IPv4 unspecified 0.0.0.0/8
/// - IPv4 broadcast 255.255.255.255
/// - IPv4 CGNAT 100.64.0.0/10 (RFC 6598) — cloud metadata risk
/// - IPv4 benchmarking 198.18.0.0/15 (RFC 2544)
/// - IPv6 loopback ::1
/// - IPv6 unspecified ::
/// - IPv6 ULA fc00::/7
/// - IPv6 link-local fe80::/10
/// - IPv6 multicast ff00::/8
/// - IPv6 documentation 2001:db8::/32 (RFC 3849) — should never appear in production
/// - IPv4-compatible and mapped IPv6 (checked recursively against IPv4 rules)
/// - IPv4-translated ::ffff:0:0:0/96 (embedded IPv4 checked recursively)
/// - NAT64 well-known 64:ff9b::/96 (embedded IPv4 checked recursively)
/// - NAT64 local-use 64:ff9b:1::/48 (RFC 8215)
/// - Teredo 2001::/32 (RFC 4380)
/// - 6to4 2002::/16 (RFC 3056)
pub fn is_private_ip(ip: &std::net::IpAddr) -> 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::<IpAddr>().unwrap()));
}
#[test]
fn test_private_10() {
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_private_172() {
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_private_192() {
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_link_local() {
assert!(is_private_ip(&"169.254.1.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_unspecified() {
assert!(is_private_ip(&"0.0.0.0".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_broadcast() {
assert!(is_private_ip(&"255.255.255.255".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_public_v4() {
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_loopback_v6() {
assert!(is_private_ip(&"::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_unspecified_v6() {
assert!(is_private_ip(&"::".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ula_v6() {
assert!(is_private_ip(&"fd00::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_link_local_v6() {
assert!(is_private_ip(&"fe80::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_public_v6() {
assert!(!is_private_ip(&"2606:4700::1".parse::<IpAddr>().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::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"2001:db8:ffff::1".parse::<IpAddr>().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::<IpAddr>().unwrap()));
}
#[test]
fn test_ipv4_mapped_v6_loopback() {
assert!(is_private_ip(
&"::ffff:127.0.0.1".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_ipv4_mapped_v6_public() {
assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ipv4_compatible_v6_private() {
assert!(is_private_ip(&"::10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"::127.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"::169.254.169.254".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(&"::8.8.8.8".parse::<IpAddr>().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::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"64:ff9b::127.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"64:ff9b::169.254.169.254".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"64:ff9b::8.8.8.8".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::<IpAddr>().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::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"::ffff:0:127.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"::ffff:0:169.254.169.254".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"::ffff:0:8.8.8.8".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"0:0:0:0:fffe:ffff:ffff:ffff".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"0:0:0:0:ffff:1:0:0".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_nat64_local_use_prefix_boundaries() {
assert!(is_private_ip(&"64:ff9b:1::".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"64:ff9b:1:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(
&"64:ff9b::ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"64:ff9b:2::".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_teredo_prefix_boundaries() {
assert!(is_private_ip(&"2001::".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"2001:0:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(
&"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"2001:1::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_6to4_prefix_boundaries() {
assert!(is_private_ip(&"2002::".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(
&"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"2003::1".parse::<IpAddr>().unwrap()));
fn blocked(s: &str) -> bool {
is_not_global_unicast(&s.parse::<IpAddr>().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::<IpAddr>().unwrap()));
}
#[test]
fn test_cgnat_end() {
// 100.127.255.254 — end of CGNAT range
assert!(is_private_ip(&"100.127.255.254".parse::<IpAddr>().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::<IpAddr>().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::<IpAddr>().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::<IpAddr>().unwrap()));
}
#[test]
fn test_benchmarking_end() {
assert!(is_private_ip(&"198.19.255.254".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_benchmarking_below_range() {
// 198.17.255.255 — just below benchmarking range
assert!(!is_private_ip(&"198.17.255.255".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_benchmarking_above_range() {
// 198.20.0.0 — just above benchmarking range
assert!(!is_private_ip(&"198.20.0.0".parse::<IpAddr>().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::<IpAddr>().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::<IpAddr>().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::<IpAddr>().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::<IpAddr>().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"));
}
}