2026-03-10 23:38:22 +00:00
|
|
|
// Copyright Sunbeam Studios 2026
|
|
|
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
|
|
|
|
|
//! DDoS detection tests.
|
2026-03-10 23:38:19 +00:00
|
|
|
//!
|
2026-03-10 23:38:22 +00:00
|
|
|
//! The detector uses ensemble inference (decision tree + MLP) with compiled-in
|
|
|
|
|
//! weights. These tests exercise the detector pipeline: event accumulation,
|
|
|
|
|
//! min_events gating, and ensemble classification.
|
2026-03-10 23:38:19 +00:00
|
|
|
|
|
|
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
|
|
|
|
|
|
|
|
use sunbeam_proxy::config::DDoSConfig;
|
|
|
|
|
use sunbeam_proxy::ddos::detector::DDoSDetector;
|
|
|
|
|
use sunbeam_proxy::ddos::features::{NormParams, NUM_FEATURES};
|
2026-03-10 23:38:22 +00:00
|
|
|
use sunbeam_proxy::ddos::model::DDoSAction;
|
2026-03-10 23:38:19 +00:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn default_ddos_config() -> DDoSConfig {
|
|
|
|
|
DDoSConfig {
|
|
|
|
|
threshold: 0.6,
|
|
|
|
|
window_secs: 60,
|
|
|
|
|
window_capacity: 1000,
|
|
|
|
|
min_events: 10,
|
|
|
|
|
enabled: true,
|
2026-03-10 23:38:22 +00:00
|
|
|
observe_only: false,
|
2026-03-10 23:38:19 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 23:38:22 +00:00
|
|
|
fn make_detector(min_events: usize) -> DDoSDetector {
|
2026-03-10 23:38:19 +00:00
|
|
|
let mut cfg = default_ddos_config();
|
|
|
|
|
cfg.min_events = min_events;
|
2026-03-10 23:38:22 +00:00
|
|
|
DDoSDetector::new(&cfg)
|
2026-03-10 23:38:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===========================================================================
|
2026-03-10 23:38:22 +00:00
|
|
|
// Normalization tests (feature-level, model-independent)
|
2026-03-10 23:38:19 +00:00
|
|
|
// ===========================================================================
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalization_clamps_out_of_range() {
|
|
|
|
|
let params = NormParams {
|
|
|
|
|
mins: [0.0; NUM_FEATURES],
|
|
|
|
|
maxs: [1.0; NUM_FEATURES],
|
|
|
|
|
};
|
|
|
|
|
let above = [2.0; NUM_FEATURES];
|
|
|
|
|
let normed = params.normalize(&above);
|
|
|
|
|
for &v in &normed {
|
|
|
|
|
assert_eq!(v, 1.0);
|
|
|
|
|
}
|
|
|
|
|
let below = [-1.0; NUM_FEATURES];
|
|
|
|
|
let normed = params.normalize(&below);
|
|
|
|
|
for &v in &normed {
|
|
|
|
|
assert_eq!(v, 0.0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalization_handles_zero_range() {
|
|
|
|
|
let params = NormParams {
|
|
|
|
|
mins: [5.0; NUM_FEATURES],
|
|
|
|
|
maxs: [5.0; NUM_FEATURES],
|
|
|
|
|
};
|
|
|
|
|
let v = [5.0; NUM_FEATURES];
|
|
|
|
|
let normed = params.normalize(&v);
|
|
|
|
|
for &val in &normed {
|
|
|
|
|
assert_eq!(val, 0.0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalization_preserves_midpoint() {
|
|
|
|
|
let params = NormParams {
|
|
|
|
|
mins: [0.0; NUM_FEATURES],
|
|
|
|
|
maxs: [100.0; NUM_FEATURES],
|
|
|
|
|
};
|
|
|
|
|
let v = [50.0; NUM_FEATURES];
|
|
|
|
|
let normed = params.normalize(&v);
|
|
|
|
|
for &val in &normed {
|
|
|
|
|
assert!((val - 0.5).abs() < 1e-10);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn norm_params_from_data_finds_extremes() {
|
|
|
|
|
let data = vec![
|
|
|
|
|
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0],
|
|
|
|
|
[10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.5, 0.5, 0.5, 0.5],
|
|
|
|
|
];
|
|
|
|
|
let params = NormParams::from_data(&data);
|
|
|
|
|
for i in 0..NUM_FEATURES {
|
|
|
|
|
assert!(params.mins[i] <= params.maxs[i]);
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(params.mins[0], 1.0);
|
|
|
|
|
assert_eq!(params.maxs[0], 10.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
// Detector integration tests (full check() pipeline)
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detector_allows_below_min_events() {
|
2026-03-10 23:38:22 +00:00
|
|
|
let detector = make_detector(10);
|
2026-03-10 23:38:19 +00:00
|
|
|
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
|
|
|
|
|
for _ in 0..9 {
|
|
|
|
|
let action = detector.check(ip, "GET", "/wp-admin", "evil.com", "bot", 0, false, false, false);
|
|
|
|
|
assert_eq!(action, DDoSAction::Allow, "should allow below min_events");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detector_ipv4_and_ipv6_tracked_separately() {
|
2026-03-10 23:38:22 +00:00
|
|
|
let detector = make_detector(3);
|
2026-03-10 23:38:19 +00:00
|
|
|
let v4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
|
|
|
|
|
let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
|
|
|
|
|
|
|
|
|
|
for _ in 0..5 {
|
|
|
|
|
detector.check(v4, "GET", "/", "example.com", "Mozilla/5.0", 0, true, false, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// v6 should still have 0 events (below min_events)
|
|
|
|
|
let action = detector.check(v6, "GET", "/", "example.com", "Mozilla/5.0", 0, true, false, true);
|
|
|
|
|
assert_eq!(action, DDoSAction::Allow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detector_normal_browsing_pattern_is_allowed() {
|
2026-03-10 23:38:22 +00:00
|
|
|
let detector = make_detector(5);
|
2026-03-10 23:38:19 +00:00
|
|
|
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 50));
|
|
|
|
|
let paths = ["/", "/about", "/products", "/products/1", "/contact",
|
|
|
|
|
"/blog", "/blog/post-1", "/docs", "/pricing", "/login",
|
|
|
|
|
"/dashboard", "/settings", "/api/me"];
|
|
|
|
|
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)";
|
|
|
|
|
|
|
|
|
|
for (i, path) in paths.iter().enumerate() {
|
|
|
|
|
let method = if i % 5 == 0 { "POST" } else { "GET" };
|
|
|
|
|
let action = detector.check(ip, method, path, "mysite.com", ua, 0, true, true, true);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
action,
|
|
|
|
|
DDoSAction::Allow,
|
|
|
|
|
"normal browsing blocked on request #{i} to {path}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detector_handles_concurrent_ips() {
|
2026-03-10 23:38:22 +00:00
|
|
|
let detector = make_detector(5);
|
2026-03-10 23:38:19 +00:00
|
|
|
for i in 0..50u8 {
|
|
|
|
|
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, i));
|
|
|
|
|
let paths = ["/", "/about", "/products", "/contact", "/blog",
|
|
|
|
|
"/docs", "/api/status"];
|
|
|
|
|
for path in &paths {
|
2026-03-10 23:38:22 +00:00
|
|
|
// has_referer=true so referer_ratio stays above the tree threshold
|
|
|
|
|
let action = detector.check(ip, "GET", path, "example.com", "Chrome", 0, true, true, true);
|
2026-03-10 23:38:19 +00:00
|
|
|
assert_eq!(action, DDoSAction::Allow,
|
|
|
|
|
"IP 10.0.0.{i} blocked on {path}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detector_ipv6_normal_traffic_is_allowed() {
|
2026-03-10 23:38:22 +00:00
|
|
|
let detector = make_detector(5);
|
2026-03-10 23:38:19 +00:00
|
|
|
let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x42));
|
|
|
|
|
let paths = ["/", "/about", "/products", "/blog", "/contact",
|
|
|
|
|
"/login", "/dashboard"];
|
|
|
|
|
for path in &paths {
|
2026-03-10 23:38:22 +00:00
|
|
|
// has_referer=true so referer_ratio stays above the tree threshold
|
2026-03-10 23:38:19 +00:00
|
|
|
let action = detector.check(ip, "GET", path, "example.com",
|
2026-03-10 23:38:22 +00:00
|
|
|
"Mozilla/5.0", 0, true, true, true);
|
2026-03-10 23:38:19 +00:00
|
|
|
assert_eq!(action, DDoSAction::Allow,
|
|
|
|
|
"IPv6 normal traffic blocked on {path}");
|
|
|
|
|
}
|
|
|
|
|
}
|