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:
parent
5b3e337c6d
commit
a2daa2e443
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue