fix(ruvector): crafted-input DoS — no panic on out-of-range indices (ADR-156 §2.2)

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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-11 20:23:12 -04:00
parent 5b3e337c6d
commit a2daa2e443
2 changed files with 100 additions and 7 deletions

View File

@ -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<f32> = (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;

View File

@ -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"
);
}
}