security(lensdb): bound the names calibrations copy, read database files through the opened regular file

This commit is contained in:
Dylan M. Taylor
2026-11-03 17:46:15 +00:00
parent 4d2fa1269b
commit 6fee4fbacd
2 changed files with 49 additions and 6 deletions
+25 -5
View File
@@ -30,6 +30,9 @@ pub use model::{Distortion, Tca, Vignetting};
const MAX_FILE_BYTES: u64 = 16 << 20;
/// Most files read from one directory.
const MAX_FILES: usize = 2000;
/// Most bytes of lens names that profiles may copy per parsed document (each calibration owns a copy): a real
/// database copies about a megabyte.
const MAX_PROFILE_COPY_BYTES: usize = 32 << 20;
/// Largest radial error (half-diagonal units) accepted when converting a distortion to the DNG polynomial.
pub const MAX_WARP_ERROR: f64 = 0.001;
/// Largest relative error accepted when converting vignetting to the DNG polynomial.
@@ -144,7 +147,8 @@ impl LensDb {
let root = xml::parse(xml)?;
let Some(top) = root.kid("lensdatabase") else { return Err(Error::Xml("no <lensdatabase> element".into())) };
let cameras = top.kids("camera").filter_map(camera).collect();
let lenses = top.kids("lens").flat_map(lens).collect();
let mut budget = MAX_PROFILE_COPY_BYTES;
let lenses = top.kids("lens").flat_map(|n| lens(n, &mut budget)).collect();
Ok(LensDb { cameras, lenses })
}
@@ -272,11 +276,22 @@ fn exif_focal_range(exif: &[String]) -> Option<(f64, f64)> {
}
fn read_capped(path: &Path) -> Result<String, Error> {
let len = std::fs::metadata(path).map_err(|e| Error::Io(e.to_string()))?.len();
if len > MAX_FILE_BYTES {
use std::io::Read;
let io = |e: std::io::Error| Error::Io(e.to_string());
// the opened file is checked, not the pathname: a link or FIFO swapped in after a pathname check is refused too
let file = std::fs::File::open(path).map_err(io)?;
let meta = file.metadata().map_err(io)?;
if !meta.is_file() {
return Err(Error::Io(format!("{}: not a regular file", path.display())));
}
if meta.len() > MAX_FILE_BYTES {
return Err(Error::TooLarge);
}
let mut bytes = Vec::new();
file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes).map_err(io)?;
if bytes.len() as u64 > MAX_FILE_BYTES {
return Err(Error::TooLarge);
}
let bytes = std::fs::read(path).map_err(|e| Error::Io(e.to_string()))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
@@ -369,7 +384,7 @@ fn positive(v: Option<f64>) -> Option<f64> {
}
/// One `<lens>` element → one profile per `<calibration>` (newer files give each its own crop factor and aspect).
fn lens(n: &xml::Node) -> Vec<Profile> {
fn lens(n: &xml::Node, copy_budget: &mut usize) -> Vec<Profile> {
let Some(maker) = n.kid_text("maker") else { return Vec::new() };
let models: Vec<String> = n.kids("model").map(|m| m.text.trim().to_string()).filter(|m| !m.is_empty()).collect();
if models.is_empty() {
@@ -379,7 +394,12 @@ fn lens(n: &xml::Node) -> Vec<Profile> {
let aspect = aspect_of(n.kid_text("aspect-ratio")).unwrap_or(1.5);
let rectilinear = n.kid_text("type").is_none_or(|t| t.eq_ignore_ascii_case("rectilinear"));
let mounts = n.kids("mount").map(|m| m.text.trim().to_string()).filter(|m| !m.is_empty()).collect::<Vec<_>>();
// every calibration owns a copy of the lens's names: calibrations past the document's shared copy budget are left out
let meta_bytes = maker.len() + models.iter().chain(&mounts).map(|m| m.len() + 1).sum::<usize>() + 1;
let take = n.kids("calibration").count().min(*copy_budget / meta_bytes);
*copy_budget -= take * meta_bytes;
n.kids("calibration")
.take(take)
.map(|cal| {
let mut p = Profile {
maker: maker.to_string(),
+24 -1
View File
@@ -232,7 +232,8 @@ fn fisheyes_get_no_rectilinear_warp_and_bad_shots_none() {
#[test]
fn load_dir_reads_xml_files_and_reports_bad_ones() {
let dir = std::env::temp_dir().join(format!("lensdb-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// exclusive: an existing (possibly planted) entry fails the test instead of being written through
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("a.xml"), DB).unwrap();
std::fs::write(dir.join("b.xml"), "<lensdatabase").unwrap();
std::fs::write(dir.join("c.txt"), "ignored").unwrap();
@@ -371,3 +372,25 @@ fn hostile_numbers_give_finite_corrections_or_none() {
assert!(db.find_camera("", "").is_none());
assert!(db.find_lens(None, " ", None).is_none());
}
#[test]
fn calibrations_past_the_shared_copy_budget_are_left_out() {
let name = "x".repeat(400_000);
let cal = "<calibration focal=\"50\"><distortion model=\"poly3\" focal=\"50\" k1=\"0.01\"/></calibration>";
let xml = format!("<lensdatabase><lens><maker>M</maker><model>{name}</model>{}</lens></lensdatabase>", cal.repeat(2000));
let db = LensDb::parse(&xml).unwrap();
assert!(!db.lenses.is_empty());
assert!(db.lenses.len() * 400_000 <= MAX_PROFILE_COPY_BYTES);
}
#[cfg(unix)]
#[test]
fn load_dir_refuses_a_non_regular_xml_entry() {
let dir = std::env::temp_dir().join(format!("lensdb-test-dev-{}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
std::os::unix::fs::symlink("/dev/zero", dir.join("z.xml")).unwrap();
let (db, errs) = LensDb::load_dir(&dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(db.is_empty());
assert_eq!(errs.len(), 1, "{errs:?}");
}