v1.0.0-rc.1

key sources (keydb/online/mapfile), overflow-safe LBA
This commit is contained in:
Matthew Jackson
2026-06-21 21:06:07 -07:00
parent a4e8fbe06c
commit 3957ac70c1
4 changed files with 287 additions and 10 deletions
+1
View File
@@ -7,3 +7,4 @@ scratch/
# internal agent context — never publish (leak-guard blocks both) # internal agent context — never publish (leak-guard blocks both)
CLAUDE.md CLAUDE.md
.claude/ .claude/
.cargo/config.toml
+6 -2
View File
@@ -1,7 +1,8 @@
[package] [package]
name = "freemkv-keysources" name = "freemkv-keysources"
version = "0.31.0" version = "1.0.0-rc.1"
edition = "2024" edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only" 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." 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" repository = "https://github.com/freemkv/freemkv-keysources"
@@ -10,11 +11,14 @@ categories = ["multimedia"]
[dependencies] [dependencies]
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. # 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. # OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
# Structural begin/end logging around the keyserver round-trip (never logs
# key material).
tracing = "0.1"
[profile.release] [profile.release]
lto = "thin" lto = "thin"
+8 -1
View File
@@ -125,7 +125,14 @@ pub fn read_sample_units(
break; break;
} }
let units_this = CHUNK_UNITS.min(total_units - unit); 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 count = (units_this * UNIT_SECTORS) as u16;
let mut buf = vec![0u8; count as usize * 2048]; let mut buf = vec![0u8; count as usize * 2048];
// `false` = no recovery retries; the reader is the raw drive/file // `false` = no recovery retries; the reader is the raw drive/file
+272 -7
View File
@@ -1,5 +1,7 @@
//! Online key-service source. //! Online key-service source.
use std::io::Read;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::time::Duration; use std::time::Duration;
use base64::Engine; use base64::Engine;
@@ -7,6 +9,133 @@ use libfreemkv::{DiscInputs, Key, KeySource};
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024; const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
const TIMEOUT_SECS: u64 = 180; 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<Vec<SocketAddr>, 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::<u16>().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::<u16>() {
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<SocketAddr> = (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<SocketAddr>) -> 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 { pub struct OnlineSource {
base_url: String, base_url: String,
@@ -61,18 +190,63 @@ impl OnlineSource {
body["title"] = serde_json::Value::String(label.to_string()); body["title"] = serde_json::Value::String(label.to_string());
} }
} }
let mut req = ureq::post(&self.base_url).timeout(Duration::from_secs(TIMEOUT_SECS)); // Resolve + SSRF-guard the host just before the POST; pin the
if !self.secret.is_empty() { // validated addresses into a redirect-disabled agent so a DNS
req = req.set("Authorization", &format!("Bearer {}", self.secret)); // rebind between config time and fetch time can't redirect the
} // request (and the bearer token) to an internal/metadata host.
let resp = match req.send_json(body) { let pinned = match resolve_and_guard(&self.base_url) {
Ok(r) => r, Ok(addrs) => addrs,
Err(_) => { Err(_) => {
self.errored = true; self.errored = true;
return None; 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, Ok(j) => j,
Err(_) => { Err(_) => {
self.errored = true; self.errored = true;
@@ -116,3 +290,94 @@ fn parse_uk(hex: &str) -> Option<[u8; 16]> {
} }
Some(out) 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);
}
}