From 3957ac70c14d1e3f02b6ce226c65299cb686a5e0 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:34:43 -0700 Subject: [PATCH] v1.0.0-rc.1 key sources (keydb/online/mapfile), overflow-safe LBA --- .gitignore | 1 + Cargo.toml | 8 +- src/lib.rs | 9 +- src/online.rs | 279 ++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 287 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 563c7ea..3b8c97f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ scratch/ # internal agent context — never publish (leak-guard blocks both) CLAUDE.md .claude/ +.cargo/config.toml diff --git a/Cargo.toml b/Cargo.toml index 775ec0d..addfb90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "freemkv-keysources" -version = "0.31.0" +version = "1.0.0-rc.1" edition = "2024" +rust-version = "1.86" license = "AGPL-3.0-only" description = "Pluggable AACS key sources (keydb, online key service, mapfile) for libfreemkv. Each source looks a disc up and hands libfreemkv a Key; the library does all derivation." repository = "https://github.com/freemkv/freemkv-keysources" @@ -10,11 +11,14 @@ categories = ["multimedia"] [dependencies] # The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. -libfreemkv = "0.31" +libfreemkv = "1.0.0-rc.1" # OnlineSource: POST disc inputs + samples to a key service over HTTP. ureq = { version = "2", features = ["json"] } serde_json = "1" base64 = "0.22" +# Structural begin/end logging around the keyserver round-trip (never logs +# key material). +tracing = "0.1" [profile.release] lto = "thin" diff --git a/src/lib.rs b/src/lib.rs index ee1ceae..03d2101 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,7 +125,14 @@ pub fn read_sample_units( break; } let units_this = CHUNK_UNITS.min(total_units - unit); - let lba = ext.start_lba + unit * UNIT_SECTORS; + // Saturate: start_lba comes from attacker-controlled UDF/MPLS + // extents; a malformed extent near u32::MAX would otherwise panic + // (debug) or wrap to a wrong LBA (release). Matches the hardened + // pattern in mux/disc.rs and verify.rs; an over-capacity LBA then + // fails cleanly via the read_sectors().is_err() break below. + let lba = ext + .start_lba + .saturating_add(unit.saturating_mul(UNIT_SECTORS)); let count = (units_this * UNIT_SECTORS) as u16; let mut buf = vec![0u8; count as usize * 2048]; // `false` = no recovery retries; the reader is the raw drive/file diff --git a/src/online.rs b/src/online.rs index fc39bbc..474086e 100644 --- a/src/online.rs +++ b/src/online.rs @@ -1,5 +1,7 @@ //! Online key-service source. +use std::io::Read; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::time::Duration; use base64::Engine; @@ -7,6 +9,133 @@ use libfreemkv::{DiscInputs, Key, KeySource}; const MAX_MKB_BYTES: usize = 10 * 1024 * 1024; const TIMEOUT_SECS: u64 = 180; +/// Hard cap on the key-service response body. A real unit-key reply is a few +/// hundred bytes; bound the read so a malicious/compromised server can't drive +/// the client to OOM with an unbounded body. +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + +// ── SSRF guard ────────────────────────────────────────────────────────────── +// +// The keyserver URL is operator-supplied and the bearer token is confidential. +// After validate_keyserver_url checked the host at config time, an attacker +// who controls the keyserver's DNS can rebind it to 169.254.169.254 (cloud +// metadata) or an RFC1918 host in the window between validation and the +// actual POST, exfiltrating the key material and the Authorization token. +// +// Defence: resolve the host once just before the POST, reject any blocked IP, +// and pin the ureq connection to those validated addresses so a subsequent DNS +// flip cannot redirect the request. Use redirects(0) so a public URL can't +// 30x-redirect to an internal host. + +/// True when `ip` must never be the target of an outbound key-service POST. +/// Blocks loopback, link-local (incl. 169.254.0.0/16 cloud metadata), all +/// RFC1918 private ranges, carrier-grade NAT, multicast, unspecified, and +/// IPv4-mapped equivalents for all of the above. +fn is_blocked_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() // 169.254.0.0/16, incl. 169.254.169.254 + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_unspecified() + || v4.is_multicast() + // Carrier-grade NAT 100.64.0.0/10. + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40) + // "This network" 0.0.0.0/8. + || v4.octets()[0] == 0 + // Class E reserved 240.0.0.0/4. + || v4.octets()[0] >= 240 + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + // Unique-local fc00::/7. + || (v6.segments()[0] & 0xfe00) == 0xfc00 + // Link-local fe80::/10. + || (v6.segments()[0] & 0xffc0) == 0xfe80 + // IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x, + // deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for + // both forms; re-check the embedded address as IPv4. + || v6 + .to_ipv4() + .map(|m| is_blocked_ip(&IpAddr::V4(m))) + == Some(true) + } + } +} + +/// Resolve `url`'s host and validate every resulting address against the SSRF +/// guard. Returns the pinned socket addresses (for use with a custom ureq +/// resolver) on success, or an error message on rejection. +fn resolve_and_guard(url: &str) -> Result, String> { + let rest = if let Some(r) = url.strip_prefix("https://") { + (r, 443u16) + } else if let Some(r) = url.strip_prefix("http://") { + (r, 80u16) + } else { + return Err("URL must start with http:// or https://".into()); + }; + let (authority, default_port) = rest; + let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority); + let authority = authority.rsplit('@').next().unwrap_or(authority); + if authority.is_empty() { + return Err("URL has no host".into()); + } + let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') { + match stripped.split_once(']') { + Some((h, after)) => { + let p = after + .strip_prefix(':') + .map(|s| s.parse::().map_err(|_| "invalid port".to_string())) + .transpose()? + .unwrap_or(default_port); + (h.to_string(), p) + } + None => return Err("malformed IPv6 host".into()), + } + } else if let Some((h, p)) = authority.rsplit_once(':') { + match p.parse::() { + Ok(p) => (h.to_string(), p), + Err(_) => (authority.to_string(), default_port), + } + } else { + (authority.to_string(), default_port) + }; + if host.is_empty() { + return Err("URL has no host".into()); + } + let addrs: Vec = (host.as_str(), port) + .to_socket_addrs() + .map_err(|e| format!("could not resolve host: {e}"))? + .collect(); + if addrs.is_empty() { + return Err("host did not resolve to any address".into()); + } + for a in &addrs { + if is_blocked_ip(&a.ip()) { + return Err(format!( + "refusing to connect to non-public address {} (SSRF guard)", + a.ip() + )); + } + } + Ok(addrs) +} + +/// Build a ureq agent that follows zero redirects (so a public URL can't +/// 30x-redirect to an internal host) and pins DNS resolution to `pinned` +/// (the addresses already validated by [`resolve_and_guard`]). +fn hardened_agent(pinned: Vec) -> ureq::Agent { + ureq::AgentBuilder::new() + .redirects(0) + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(TIMEOUT_SECS)) + .resolver(move |_netloc: &str| Ok(pinned.clone())) + .build() +} pub struct OnlineSource { base_url: String, @@ -61,18 +190,63 @@ impl OnlineSource { body["title"] = serde_json::Value::String(label.to_string()); } } - let mut req = ureq::post(&self.base_url).timeout(Duration::from_secs(TIMEOUT_SECS)); - if !self.secret.is_empty() { - req = req.set("Authorization", &format!("Bearer {}", self.secret)); - } - let resp = match req.send_json(body) { - Ok(r) => r, + // Resolve + SSRF-guard the host just before the POST; pin the + // validated addresses into a redirect-disabled agent so a DNS + // rebind between config time and fetch time can't redirect the + // request (and the bearer token) to an internal/metadata host. + let pinned = match resolve_and_guard(&self.base_url) { + Ok(addrs) => addrs, Err(_) => { self.errored = true; return None; } }; - let json: serde_json::Value = match resp.into_json() { + let agent = hardened_agent(pinned); + let mut req = agent.post(&self.base_url); + if !self.secret.is_empty() { + req = req.set("Authorization", &format!("Bearer {}", self.secret)); + } + // Begin/end around the keyserver round-trip — a slow or unresponsive + // service is the suspected DVD-scan hang. The agent is built with a + // 10s connect + bounded read timeout (see `hardened_agent`), so this + // call can never block forever; we log the timing so a slow round-trip + // is visible. SECURITY: never log `body` — it carries base64 key + // material. + tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin"); + let post_t0 = std::time::Instant::now(); + let resp = match req.send_json(body) { + Ok(r) => r, + Err(_) => { + tracing::warn!( + target: "freemkv::keysource", + phase = "keyserver_post", + elapsed_ms = post_t0.elapsed().as_millis() as u64, + "keyserver request failed (timeout, network, or HTTP error)" + ); + self.errored = true; + return None; + } + }; + tracing::info!( + target: "freemkv::keysource", + phase = "keyserver_post", + elapsed_ms = post_t0.elapsed().as_millis() as u64, + "end" + ); + // Bounded read: cap the body so a hostile server can't OOM the client. + // Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body. + let mut buf = Vec::new(); + if resp + .into_reader() + .take(MAX_RESPONSE_BYTES as u64 + 1) + .read_to_end(&mut buf) + .is_err() + || buf.len() > MAX_RESPONSE_BYTES + { + self.errored = true; + return None; + } + let json: serde_json::Value = match serde_json::from_slice(&buf) { Ok(j) => j, Err(_) => { self.errored = true; @@ -116,3 +290,94 @@ fn parse_uk(hex: &str) -> Option<[u8; 16]> { } Some(out) } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + // ── is_blocked_ip ────────────────────────────────────────────────────── + + #[test] + fn ssrf_guard_blocks_loopback_private_and_metadata() { + // Loopback. + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); + // RFC1918 private ranges. + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)))); + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); + // Cloud-metadata anycast (link-local 169.254.0.0/16). + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new( + 169, 254, 169, 254 + )))); + // Carrier-grade NAT 100.64.0.0/10 and "this network" 0.0.0.0/8. + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)))); + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)))); + // IPv6 loopback, ULA fc00::/7, link-local fe80::/10. + assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new( + 0xfd00, 0, 0, 0, 0, 0, 0, 1 + )))); + assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new( + 0xfe80, 0, 0, 0, 0, 0, 0, 1 + )))); + // IPv4-mapped loopback ::ffff:127.0.0.1 must also be blocked. + assert!(is_blocked_ip(&IpAddr::V6( + Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped() + ))); + // IPv4-compatible loopback ::127.0.0.1 (= ::7f00:1, deprecated RFC + // 4291 §2.5.5.1) — to_ipv4_mapped() misses this form; to_ipv4() catches + // both mapped and compatible. + assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new( + 0, 0, 0, 0, 0, 0, 0x7f00, 0x0001 + )))); + // Class E reserved 240.0.0.0/4. + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1)))); + assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new( + 255, 255, 255, 254 + )))); + } + + #[test] + fn ssrf_guard_allows_public_ips() { + assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)))); + // Public IPv6 (Cloudflare DNS 2606:4700:4700::1111). + assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new( + 0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111 + )))); + } + + // ── resolve_and_guard ────────────────────────────────────────────────── + + #[test] + fn resolve_and_guard_rejects_internal_literals() { + // Numeric literals resolve without DNS — must still be rejected. + assert!(resolve_and_guard("http://127.0.0.1/keys").is_err()); + assert!(resolve_and_guard("http://169.254.169.254/latest/meta-data/").is_err()); + assert!(resolve_and_guard(&format!("http://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err()); + assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}/keys", 192, 168, 0, 1)).is_err()); + assert!(resolve_and_guard("http://[::1]:9000/keys").is_err()); + } + + #[test] + fn resolve_and_guard_rejects_bad_scheme() { + assert!(resolve_and_guard("ftp://example.com/keys").is_err()); + assert!(resolve_and_guard("file:///etc/passwd").is_err()); + assert!(resolve_and_guard("not a url").is_err()); + assert!(resolve_and_guard("").is_err()); + } + + #[test] + fn resolve_and_guard_accepts_public_literal() { + // Public numeric hosts resolve without DNS — must be accepted. + let addrs = resolve_and_guard("https://8.8.8.8/keys").expect("public IP must be accepted"); + assert!(!addrs.is_empty()); + assert_eq!(addrs[0].port(), 443); + + let addrs = + resolve_and_guard("http://1.1.1.1:8080/keys").expect("public IP with port accepted"); + assert!(!addrs.is_empty()); + assert_eq!(addrs[0].port(), 8080); + } +}