From a2daa2e443c4444a8de3cf6d747d688b1f343983 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 20:23:12 -0400 Subject: [PATCH] =?UTF-8?q?fix(ruvector):=20crafted-input=20DoS=20?= =?UTF-8?q?=E2=80=94=20no=20panic=20on=20out-of-range=20indices=20(ADR-156?= =?UTF-8?q?=20=C2=A72.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fix: two functions on a fusion/localisation path that can carry network-sourced multistatic frames panicked on crafted input (remote DoS). - triangulation::solve_triangulation indexed ap_positions[0] (empty table) and ap_positions[i]/[j] (crafted out-of-range AP index in a TDoA tuple). Now uses .first()? / .get(i)? / .get(j)? — returns None, never panics. - heartbeat::band_power computed n_freq_bins-1 (usize underflow on a zero-bin spectrogram) and did not clamp low_bin. Now guards n_freq_bins==0 and clamps both bounds into [0,last]; returns 0.0 for empty/inverted ranges. Tests (each panics on old code, verified by revert): triangulation_out_of_range_index_returns_none_no_panic, triangulation_empty_ap_positions_returns_none_no_panic, heartbeat_band_power_zero_bins_no_panic, heartbeat_band_power_out_of_range_bounds_no_panic. Co-Authored-By: claude-flow --- .../src/mat/heartbeat.rs | 56 ++++++++++++++++++- .../src/mat/triangulation.rs | 51 +++++++++++++++-- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/v2/crates/wifi-densepose-ruvector/src/mat/heartbeat.rs b/v2/crates/wifi-densepose-ruvector/src/mat/heartbeat.rs index 9e44a655..ccc9486c 100644 --- a/v2/crates/wifi-densepose-ruvector/src/mat/heartbeat.rs +++ b/v2/crates/wifi-densepose-ruvector/src/mat/heartbeat.rs @@ -59,12 +59,28 @@ impl CompressedHeartbeatSpectrogram { /// Decodes only the bins in the requested range and returns the mean of /// the squared decoded values over the last up to 100 frames. /// Returns `0.0` for an empty range. + /// + /// # Robustness (ADR-156 §finding 2) + /// + /// Both bounds are clamped to the valid bin range, so crafted / out-of-range + /// `low_bin`/`high_bin` (including a band that starts past the last bin, or a + /// zero-bin spectrogram) return `0.0` instead of an index or subtraction + /// overflow panic. This guards a path that may be driven by external CSI. pub fn band_power(&self, low_bin: usize, high_bin: usize) -> f32 { - let n = (high_bin.min(self.n_freq_bins - 1) + 1).saturating_sub(low_bin); - if n == 0 { + // Empty spectrogram: no bins to read (avoids `n_freq_bins - 1` underflow). + if self.n_freq_bins == 0 { return 0.0; } - (low_bin..=high_bin.min(self.n_freq_bins - 1)) + let last = self.n_freq_bins - 1; + // Clamp BOTH bounds into [0, last]; if low > high after clamping the + // range is empty and we return 0.0 (no panic, no out-of-range index). + let lo = low_bin.min(last); + let hi = high_bin.min(last); + if lo > hi { + return 0.0; + } + let n = hi - lo + 1; + (lo..=hi) .map(|b| { let mut out = Vec::new(); tt_segment::decode(&self.encoded[b], &mut out); @@ -98,6 +114,40 @@ mod tests { ); } + /// ADR-156 §finding 2: a zero-bin spectrogram must NOT panic in + /// `band_power`. Before the fix, `self.n_freq_bins - 1` underflowed (usize + /// `0 - 1`), panicking in debug and producing `usize::MAX` (then an + /// out-of-range index) in release — both DoS-able on an externally-driven + /// CSI path. + #[test] + fn heartbeat_band_power_zero_bins_no_panic() { + let spec = CompressedHeartbeatSpectrogram::new(0); + assert_eq!( + spec.band_power(0, 10), + 0.0, + "zero-bin spectrogram must return 0.0, not panic" + ); + } + + /// ADR-156 §finding 2: out-of-range / inverted band bounds are clamped and + /// return a finite value (or 0.0), never panicking. + #[test] + fn heartbeat_band_power_out_of_range_bounds_no_panic() { + let n_freq_bins = 16; + let mut spec = CompressedHeartbeatSpectrogram::new(n_freq_bins); + for i in 0..5 { + let column: Vec = (0..n_freq_bins).map(|b| (i + b) as f32 * 0.1).collect(); + spec.push_column(&column); + } + // high_bin far past the last valid bin → clamped, no out-of-range index. + let p1 = spec.band_power(2, 9999); + assert!(p1.is_finite() && p1 >= 0.0, "clamped high bound must be finite"); + // low_bin past the last bin → empty range → 0.0 (no panic). + assert_eq!(spec.band_power(100, 200), 0.0); + // inverted bounds (low > high) → 0.0. + assert_eq!(spec.band_power(10, 3), 0.0); + } + #[test] fn heartbeat_band_power_runs() { let n_freq_bins = 16; diff --git a/v2/crates/wifi-densepose-ruvector/src/mat/triangulation.rs b/v2/crates/wifi-densepose-ruvector/src/mat/triangulation.rs index d174e97e..3a1a5950 100644 --- a/v2/crates/wifi-densepose-ruvector/src/mat/triangulation.rs +++ b/v2/crates/wifi-densepose-ruvector/src/mat/triangulation.rs @@ -18,7 +18,15 @@ use ruvector_solver::types::CsrMatrix; /// # Returns /// /// Estimated `(x, y)` position in metres, or `None` if fewer than 3 TDoA -/// measurements are provided or the solver fails to converge. +/// measurements are provided, `ap_positions` is empty, any measurement +/// references an out-of-range AP index, or the solver fails to converge. +/// +/// # Robustness (ADR-156 §finding 2) +/// +/// Inputs may originate from network-sourced multistatic frames, so crafted +/// AP indices must NOT panic. Any TDoA tuple whose `i`/`j` is out of range for +/// `ap_positions` (or an empty `ap_positions`) returns `None` instead of an +/// out-of-bounds index panic (a DoS vector). /// /// # Algorithm /// @@ -34,15 +42,17 @@ pub fn solve_triangulation( } const C: f32 = 3e8_f32; // speed of light, m/s - let (x_ref, y_ref) = ap_positions[0]; + // Guard: empty AP table cannot anchor a reference (ADR-156 §finding 2). + let &(x_ref, y_ref) = ap_positions.first()?; let mut col0 = Vec::new(); let mut col1 = Vec::new(); let mut b = Vec::new(); for &(i, j, tdoa) in tdoa_measurements { - let (xi, yi) = ap_positions[i]; - let (xj, yj) = ap_positions[j]; + // Guard against crafted out-of-range indices (no index panic / DoS). + let &(xi, yi) = ap_positions.get(i)?; + let &(xj, yj) = ap_positions.get(j)?; col0.push(xi - xj); col1.push(yi - yj); b.push( @@ -136,4 +146,37 @@ mod tests { "fewer than 3 measurements must return None" ); } + + /// ADR-156 §finding 2 (security / DoS): crafted out-of-range AP indices in + /// TDoA measurements must NOT panic — they return `None`. Before the fix the + /// `ap_positions[i]` / `ap_positions[j]` indexing panicked on these inputs, + /// a remote-triggerable denial-of-service on a fusion path that can carry + /// network-sourced multistatic frames. + #[test] + fn triangulation_out_of_range_index_returns_none_no_panic() { + let ap_positions = vec![(0.0_f32, 0.0), (1.0, 0.0), (1.0, 1.0)]; + // AP index 99 does not exist (3 APs ⇒ valid indices 0..=2). + let crafted = vec![(0, 99, 1e-9_f32), (1, 0, 1e-9), (2, 0, 1e-9)]; + let result = solve_triangulation(&crafted, &ap_positions); + assert!( + result.is_none(), + "crafted out-of-range AP index must return None, not panic" + ); + + // Reference index out of range (i = 5). + let crafted2 = vec![(5, 0, 1e-9_f32), (1, 0, 1e-9), (2, 0, 1e-9)]; + assert!(solve_triangulation(&crafted2, &ap_positions).is_none()); + } + + /// ADR-156 §finding 2: an empty AP table must return `None`, not panic on + /// `ap_positions[0]`. + #[test] + fn triangulation_empty_ap_positions_returns_none_no_panic() { + let empty: Vec<(f32, f32)> = Vec::new(); + let measurements = vec![(0, 1, 1e-9_f32), (1, 2, 1e-9), (2, 0, 1e-9)]; + assert!( + solve_triangulation(&measurements, &empty).is_none(), + "empty AP table must return None, not panic" + ); + } }