From f42d7ef41ac4cf5b9bde70052c6e157d14911eef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 21:51:23 -0400 Subject: [PATCH] security(codecs): index FITS header keywords for O(1) lookup Header::get scanned every card linearly, and axis resolution looks up NAXIS1..NAXISn while find_image/data_len re-resolve the axes. Both the commentary-card count and the NAXIS count are file-controlled, so a crafted header multiplied them into quadratic header-processing work without a correspondingly large decoded image. Build a first-occurrence keyword index once in parse_header (skipping HIERARCH cards, matching the prior filter) and use it in get(). Finding: eb2121d8-9dee-4422-bd57-f78dce60a61b (CWE-407) --- crates/codecs/src/codecs/fits.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 7e3f92ae67..214d968944 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -22,6 +22,8 @@ //! (strings longer than one card on `CONTINUE` cards). //! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused. +use std::collections::HashMap; + use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; @@ -106,13 +108,19 @@ fn parse_card(raw: &[u8]) -> Card { struct Header { cards: Vec, + /// First-occurrence index of each non-HIERARCH keyword, so `get` is O(1). The card + /// count and the number of looked-up keywords (NAXIS1..NAXISn) are both file-controlled, + /// so a linear scan per lookup lets a crafted header multiply commentary cards by axis + /// count into quadratic work. + index: HashMap, /// Offset of the data unit. data_start: usize, } impl Header { fn get(&self, key: &str) -> Option<&Value> { - self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref()) + let c = &self.cards[*self.index.get(key)?]; + c.value.as_ref() } fn int(&self, key: &str) -> Option { @@ -167,7 +175,15 @@ fn parse_header(b: &[u8], start: usize) -> Result { cards.push(card); } let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?; - Ok(Header { cards, data_start }) + // Index each non-HIERARCH keyword to its first occurrence, matching the previous + // `find(|c| !c.hierarch && c.key == key)` lookup while making `get` O(1). + let mut index = HashMap::with_capacity(cards.len()); + for (i, c) in cards.iter().enumerate() { + if !c.hierarch { + index.entry(c.key.clone()).or_insert(i); + } + } + Ok(Header { cards, index, data_start }) } /// The axis lengths (`NAXIS1`, `NAXIS2`, …).