//! glTF companion-resource discovery and the SSRF/path-containment guard. //! //! `three-d-asset`'s glTF dependency discovery is private, so Polymodel scans the //! primary `.gltf` JSON itself for the non-`data:` URIs it must fetch (external buffers //! and images). The guard ensures only safe, in-directory, relative URIs are ever //! fetched: absolute (`https://…`, `/etc/…`), arbitrary-scheme (`file:`, `blob:`), //! protocol-relative (`//host`), backslash, and `..`-traversal URIs are rejected — //! including percent-encoded or backslash-obfuscated traversal. use serde_json::Value; /// Discover companion-resource URIs referenced by a `.gltf` document: every non-`data:` /// `buffers[].uri` and `images[].uri`. /// /// Returns empty if `primary` is not a JSON object (e.g. a GLB, which is binary), so /// callers can feed any primary without branching on format. pub fn discover_companion_uris(primary: &[u8]) -> Vec { let Ok(Value::Object(root)) = serde_json::from_slice::(primary) else { return Vec::new(); }; let mut uris = Vec::new(); for key in ["buffers", "images"] { if let Some(Value::Array(items)) = root.get(key) { for item in items { if let Some(Value::String(uri)) = item.get("uri") && !uri.starts_with("data:") { uris.push(uri.clone()); } } } } uris } /// A companion URI is fetchable only if it is safe and relative: no scheme, no /// backslash, not an absolute or protocol-relative path, and its normalized path does /// not escape the asset's directory via `..`. /// /// Validation runs on a **percent-decoded** copy so encoded traversal (`%2e%2e`, /// `..%2f`, `%5c`) cannot bypass it; any residual `%` after one decode (double encoding /// like `%252e`) or invalid encoding is rejected. Only the decoded form is validated; /// callers keep the original raw URI as the companion key. pub fn is_safe_relative_uri(uri: &str) -> bool { if uri.contains('\\') { return false; } let Some(decoded) = percent_decode_once(uri) else { return false; }; if decoded.contains('%') { return false; } if decoded.contains('\\') || decoded.contains(':') || decoded.starts_with('/') { return false; } let mut depth = 0isize; for segment in decoded.split('/') { match segment { "" | "." => {} ".." => { depth -= 1; if depth < 0 { return false; } } _ => depth += 1, } } true } fn percent_decode_once(input: &str) -> Option { let bytes = input.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { let h = hex_digit(bytes.get(i + 1).copied())?; let l = hex_digit(bytes.get(i + 2).copied())?; out.push((h << 4) | l); i += 3; } else { out.push(bytes[i]); i += 1; } } String::from_utf8(out).ok() } fn hex_digit(b: Option) -> Option { let b = b?; match b { b'0'..=b'9' => Some(b - b'0'), b'a'..=b'f' => Some(b - b'a' + 10), b'A'..=b'F' => Some(b - b'A' + 10), _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn discovers_buffer_and_image_uris_skipping_data_urls() { let gltf = br#"{ "asset": {"version": "2.0"}, "buffers": [ {"uri": "cube.bin"}, {"uri": "data:application/octet-stream;base64,AAAA"} ], "images": [{"uri": "tex.png"}, {"bufferView": 0}] }"#; let mut uris = discover_companion_uris(gltf); uris.sort(); assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]); } #[test] fn discovers_no_uris_from_non_json() { assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty()); assert!(discover_companion_uris(b"not json at all").is_empty()); } #[test] fn accepts_safe_relative_uris() { assert!(is_safe_relative_uri("cube.bin")); assert!(is_safe_relative_uri("textures/foo.png")); assert!(is_safe_relative_uri("a/b/c.bin")); assert!(is_safe_relative_uri("./cube.bin")); assert!(is_safe_relative_uri("a/../b.bin")); assert!(is_safe_relative_uri("my%20file.bin")); } #[test] fn rejects_absolute_scheme_protocol_relative_and_traversal_uris() { assert!(!is_safe_relative_uri("https://evil.example/x.bin")); assert!(!is_safe_relative_uri("http://evil.example/x.bin")); assert!(!is_safe_relative_uri("file:///etc/passwd")); assert!(!is_safe_relative_uri("blob:abc")); assert!(!is_safe_relative_uri("/etc/passwd")); assert!(!is_safe_relative_uri("//evil.example/x.bin")); assert!(!is_safe_relative_uri("../escape.bin")); assert!(!is_safe_relative_uri("a/../../escape.bin")); } #[test] fn rejects_encoded_and_backslash_traversal() { assert!(!is_safe_relative_uri("%2e%2e/escape.bin")); assert!(!is_safe_relative_uri("..%2fescape.bin")); assert!(!is_safe_relative_uri("%2e%2e%2fescape.bin")); assert!(!is_safe_relative_uri("textures/%2e%2e/%2e%2e/escape.bin")); assert!(!is_safe_relative_uri("%252e%252e/escape.bin")); assert!(!is_safe_relative_uri("..\\escape.bin")); assert!(!is_safe_relative_uri("textures\\..\\escape.bin")); assert!(!is_safe_relative_uri("%5c%5e..")); assert!(!is_safe_relative_uri("ab%2")); assert!(!is_safe_relative_uri("ab%zz")); } }