mirror of
https://github.com/telemt/telemt.git
synced 2026-04-17 10:34:11 +03:00
Implement aggressive shape hardening mode and related tests
This commit is contained in:
@@ -64,7 +64,7 @@ async fn user_connection_reservation_drop_enqueues_cleanup_synchronously() {
|
||||
drop(reservation);
|
||||
|
||||
// The IP is now inside the cleanup_queue, check that the queue has length 1
|
||||
let queue_len = ip_tracker.cleanup_queue.lock().unwrap().len();
|
||||
let queue_len = ip_tracker.cleanup_queue_len_for_tests();
|
||||
assert_eq!(
|
||||
queue_len, 1,
|
||||
"Reservation drop must push directly to synchronized IP queue"
|
||||
|
||||
@@ -451,6 +451,8 @@ async fn timing_classifier_normalized_spread_is_not_worse_than_baseline_for_conn
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_under_normalization()
|
||||
{
|
||||
const SAMPLE_COUNT: usize = 6;
|
||||
|
||||
let pairs = [
|
||||
(PathClass::ConnectFail, PathClass::ConnectSuccess),
|
||||
(PathClass::ConnectFail, PathClass::SlowBackend),
|
||||
@@ -461,12 +463,14 @@ async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_u
|
||||
let mut baseline_sum = 0.0f64;
|
||||
let mut hardened_sum = 0.0f64;
|
||||
let mut pair_count = 0usize;
|
||||
let acc_quant_step = 1.0 / (2 * SAMPLE_COUNT) as f64;
|
||||
let tolerated_pair_regression = acc_quant_step + 0.03;
|
||||
|
||||
for (a, b) in pairs {
|
||||
let baseline_a = collect_timing_samples(a, false, 6).await;
|
||||
let baseline_b = collect_timing_samples(b, false, 6).await;
|
||||
let hardened_a = collect_timing_samples(a, true, 6).await;
|
||||
let hardened_b = collect_timing_samples(b, true, 6).await;
|
||||
let baseline_a = collect_timing_samples(a, false, SAMPLE_COUNT).await;
|
||||
let baseline_b = collect_timing_samples(b, false, SAMPLE_COUNT).await;
|
||||
let hardened_a = collect_timing_samples(a, true, SAMPLE_COUNT).await;
|
||||
let hardened_b = collect_timing_samples(b, true, SAMPLE_COUNT).await;
|
||||
|
||||
let baseline_acc = best_threshold_accuracy_u128(
|
||||
&bucketize_ms(&baseline_a, 20),
|
||||
@@ -482,11 +486,15 @@ async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_u
|
||||
// Guard hard only on informative baseline pairs.
|
||||
if baseline_acc >= 0.75 {
|
||||
assert!(
|
||||
hardened_acc <= baseline_acc + 0.05,
|
||||
"normalization should not materially worsen informative pair: baseline={baseline_acc:.3} hardened={hardened_acc:.3}"
|
||||
hardened_acc <= baseline_acc + tolerated_pair_regression,
|
||||
"normalization should not materially worsen informative pair: baseline={baseline_acc:.3} hardened={hardened_acc:.3} tolerated={tolerated_pair_regression:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"timing_classifier_pair baseline={baseline_acc:.3} hardened={hardened_acc:.3} tolerated_pair_regression={tolerated_pair_regression:.3}"
|
||||
);
|
||||
|
||||
if hardened_acc + 0.05 <= baseline_acc {
|
||||
meaningful_improvement_seen = true;
|
||||
}
|
||||
@@ -500,7 +508,7 @@ async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_u
|
||||
let hardened_avg = hardened_sum / pair_count as f64;
|
||||
|
||||
assert!(
|
||||
hardened_avg <= baseline_avg + 0.08,
|
||||
hardened_avg <= baseline_avg + 0.10,
|
||||
"normalization should not materially increase average pairwise separability: baseline_avg={baseline_avg:.3} hardened_avg={hardened_avg:.3}"
|
||||
);
|
||||
|
||||
|
||||
107
src/proxy/tests/masking_aggressive_mode_security_tests.rs
Normal file
107
src/proxy/tests/masking_aggressive_mode_security_tests.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
async fn capture_forwarded_len_with_mode(
|
||||
body_sent: usize,
|
||||
close_client_after_write: bool,
|
||||
aggressive_mode: bool,
|
||||
above_cap_blur: bool,
|
||||
above_cap_blur_max_bytes: usize,
|
||||
) -> usize {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = true;
|
||||
config.censorship.mask_shape_hardening_aggressive_mode = aggressive_mode;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
config.censorship.mask_shape_above_cap_blur = above_cap_blur;
|
||||
config.censorship.mask_shape_above_cap_blur_max_bytes = above_cap_blur_max_bytes;
|
||||
|
||||
let accept_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut got = Vec::new();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut got)).await;
|
||||
got.len()
|
||||
});
|
||||
|
||||
let (server_reader, mut client_writer) = duplex(64 * 1024);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(64 * 1024);
|
||||
let peer: SocketAddr = "198.51.100.248:57248".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let mut probe = vec![0u8; 5 + body_sent];
|
||||
probe[0] = 0x16;
|
||||
probe[1] = 0x03;
|
||||
probe[2] = 0x01;
|
||||
probe[3..5].copy_from_slice(&7000u16.to_be_bytes());
|
||||
probe[5..].fill(0x31);
|
||||
|
||||
let fallback = tokio::spawn(async move {
|
||||
handle_bad_client(
|
||||
server_reader,
|
||||
client_visible_writer,
|
||||
&probe,
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
if close_client_after_write {
|
||||
client_writer.shutdown().await.unwrap();
|
||||
} else {
|
||||
client_writer.write_all(b"keepalive").await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(170)).await;
|
||||
drop(client_writer);
|
||||
}
|
||||
|
||||
let _ = tokio::time::timeout(Duration::from_secs(4), fallback)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(4), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggressive_mode_shapes_backend_silent_non_eof_path() {
|
||||
let body_sent = 17usize;
|
||||
let floor = 512usize;
|
||||
|
||||
let legacy = capture_forwarded_len_with_mode(body_sent, false, false, false, 0).await;
|
||||
let aggressive = capture_forwarded_len_with_mode(body_sent, false, true, false, 0).await;
|
||||
|
||||
assert!(legacy < floor, "legacy mode should keep timeout path unshaped");
|
||||
assert!(
|
||||
aggressive >= floor,
|
||||
"aggressive mode must shape backend-silent non-EOF paths (aggressive={aggressive}, floor={floor})"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggressive_mode_enforces_positive_above_cap_blur() {
|
||||
let body_sent = 5000usize;
|
||||
let base = 5 + body_sent;
|
||||
|
||||
for _ in 0..48 {
|
||||
let observed = capture_forwarded_len_with_mode(body_sent, true, true, true, 1).await;
|
||||
assert!(
|
||||
observed > base,
|
||||
"aggressive mode must not emit exact base length when blur is enabled (observed={observed}, base={base})"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1375,6 +1375,7 @@ async fn relay_to_mask_keeps_backend_to_client_flow_when_client_to_backend_stall
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -1494,7 +1495,17 @@ async fn relay_to_mask_timeout_cancels_and_drops_all_io_endpoints() {
|
||||
let timed = timeout(
|
||||
Duration::from_millis(40),
|
||||
relay_to_mask(
|
||||
reader, writer, mask_read, mask_write, b"", false, 0, 0, false, 0,
|
||||
reader,
|
||||
writer,
|
||||
mask_read,
|
||||
mask_write,
|
||||
b"",
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
182
src/proxy/tests/masking_shape_bypass_blackhat_tests.rs
Normal file
182
src/proxy/tests/masking_shape_bypass_blackhat_tests.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
async fn capture_forwarded_len_with_optional_eof(
|
||||
body_sent: usize,
|
||||
shape_hardening: bool,
|
||||
above_cap_blur: bool,
|
||||
above_cap_blur_max_bytes: usize,
|
||||
close_client_after_write: bool,
|
||||
) -> usize {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = shape_hardening;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
config.censorship.mask_shape_above_cap_blur = above_cap_blur;
|
||||
config.censorship.mask_shape_above_cap_blur_max_bytes = above_cap_blur_max_bytes;
|
||||
|
||||
let accept_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut got = Vec::new();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut got)).await;
|
||||
got.len()
|
||||
});
|
||||
|
||||
let (server_reader, mut client_writer) = duplex(64 * 1024);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(64 * 1024);
|
||||
let peer: SocketAddr = "198.51.100.241:57241".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let mut probe = vec![0u8; 5 + body_sent];
|
||||
probe[0] = 0x16;
|
||||
probe[1] = 0x03;
|
||||
probe[2] = 0x01;
|
||||
probe[3..5].copy_from_slice(&7000u16.to_be_bytes());
|
||||
probe[5..].fill(0x73);
|
||||
|
||||
let fallback = tokio::spawn(async move {
|
||||
handle_bad_client(
|
||||
server_reader,
|
||||
client_visible_writer,
|
||||
&probe,
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
if close_client_after_write {
|
||||
client_writer.shutdown().await.unwrap();
|
||||
} else {
|
||||
client_writer.write_all(b"keepalive").await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(170)).await;
|
||||
drop(client_writer);
|
||||
}
|
||||
|
||||
let _ = tokio::time::timeout(Duration::from_secs(4), fallback)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(4), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team detector: shaping on non-EOF timeout path is disabled by design to prevent post-timeout tail leaks"]
|
||||
async fn security_shape_padding_applies_without_client_eof_when_backend_silent() {
|
||||
let body_sent = 17usize;
|
||||
let hardened_floor = 512usize;
|
||||
|
||||
let with_eof = capture_forwarded_len_with_optional_eof(body_sent, true, false, 0, true).await;
|
||||
let without_eof =
|
||||
capture_forwarded_len_with_optional_eof(body_sent, true, false, 0, false).await;
|
||||
|
||||
assert!(
|
||||
with_eof >= hardened_floor,
|
||||
"EOF path should be shaped to floor (with_eof={with_eof}, floor={hardened_floor})"
|
||||
);
|
||||
assert!(
|
||||
without_eof >= hardened_floor,
|
||||
"non-EOF path should also be shaped when backend is silent (without_eof={without_eof}, floor={hardened_floor})"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team detector: blur currently allows zero-extra sample by design within [0..=max] bound"]
|
||||
async fn security_above_cap_blur_never_emits_exact_base_length() {
|
||||
let body_sent = 5000usize;
|
||||
let base = 5 + body_sent;
|
||||
let max_blur = 1usize;
|
||||
|
||||
for _ in 0..64 {
|
||||
let observed =
|
||||
capture_forwarded_len_with_optional_eof(body_sent, true, true, max_blur, true).await;
|
||||
assert!(
|
||||
observed > base,
|
||||
"above-cap blur must add at least one byte when enabled (observed={observed}, base={base})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team detector: shape padding currently depends on EOF, enabling idle-timeout bypass probes"]
|
||||
async fn redteam_detector_shape_padding_must_not_depend_on_client_eof() {
|
||||
let body_sent = 17usize;
|
||||
let hardened_floor = 512usize;
|
||||
|
||||
let with_eof = capture_forwarded_len_with_optional_eof(body_sent, true, false, 0, true).await;
|
||||
let without_eof =
|
||||
capture_forwarded_len_with_optional_eof(body_sent, true, false, 0, false).await;
|
||||
|
||||
assert!(
|
||||
with_eof >= hardened_floor,
|
||||
"sanity check failed: EOF path should be shaped to floor (with_eof={with_eof}, floor={hardened_floor})"
|
||||
);
|
||||
|
||||
assert!(
|
||||
without_eof >= hardened_floor,
|
||||
"strict anti-probing model expects shaping even without EOF; observed without_eof={without_eof}, floor={hardened_floor}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team detector: zero-extra above-cap blur samples leak exact class boundary"]
|
||||
async fn redteam_detector_above_cap_blur_must_never_emit_exact_base_length() {
|
||||
let body_sent = 5000usize;
|
||||
let base = 5 + body_sent;
|
||||
let mut saw_exact_base = false;
|
||||
let max_blur = 1usize;
|
||||
|
||||
for _ in 0..96 {
|
||||
let observed =
|
||||
capture_forwarded_len_with_optional_eof(body_sent, true, true, max_blur, true).await;
|
||||
if observed == base {
|
||||
saw_exact_base = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!saw_exact_base,
|
||||
"strict anti-classifier model expects >0 blur always; observed exact base length leaks class"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team detector: disjoint above-cap ranges enable near-perfect size-class classification"]
|
||||
async fn redteam_detector_above_cap_blur_ranges_for_far_classes_should_overlap() {
|
||||
let mut a_min = usize::MAX;
|
||||
let mut a_max = 0usize;
|
||||
let mut b_min = usize::MAX;
|
||||
let mut b_max = 0usize;
|
||||
|
||||
for _ in 0..48 {
|
||||
let a = capture_forwarded_len_with_optional_eof(5000, true, true, 64, true).await;
|
||||
let b = capture_forwarded_len_with_optional_eof(7000, true, true, 64, true).await;
|
||||
a_min = a_min.min(a);
|
||||
a_max = a_max.max(a);
|
||||
b_min = b_min.min(b);
|
||||
b_max = b_max.max(b);
|
||||
}
|
||||
|
||||
let overlap = a_min <= b_max && b_min <= a_max;
|
||||
assert!(
|
||||
overlap,
|
||||
"strict anti-classifier model expects overlapping output bands; class_a=[{a_min},{a_max}] class_b=[{b_min},{b_max}]"
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ async fn run_relay_case(
|
||||
cap,
|
||||
above_cap_blur,
|
||||
above_cap_blur_max_bytes,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -56,14 +56,14 @@ fn shape_bucket_never_drops_below_total_for_valid_ranges() {
|
||||
#[tokio::test]
|
||||
async fn maybe_write_shape_padding_writes_exact_delta() {
|
||||
let mut writer = CountingWriter::new();
|
||||
maybe_write_shape_padding(&mut writer, 1200, true, 1000, 1500, false, 0).await;
|
||||
maybe_write_shape_padding(&mut writer, 1200, true, 1000, 1500, false, 0, false).await;
|
||||
assert_eq!(writer.written, 300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_write_shape_padding_skips_when_disabled() {
|
||||
let mut writer = CountingWriter::new();
|
||||
maybe_write_shape_padding(&mut writer, 1200, false, 1000, 1500, false, 0).await;
|
||||
maybe_write_shape_padding(&mut writer, 1200, false, 1000, 1500, false, 0, false).await;
|
||||
assert_eq!(writer.written, 0);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ async fn relay_to_mask_applies_cap_clamped_padding_for_non_power_of_two_cap() {
|
||||
1500,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -238,6 +238,11 @@ fn desync_dedup_cache_is_bounded() {
|
||||
|
||||
#[test]
|
||||
fn quota_user_lock_cache_reuses_entry_for_same_user() {
|
||||
let _guard = super::quota_user_lock_test_scope();
|
||||
|
||||
let map = QUOTA_USER_LOCKS.get_or_init(DashMap::new);
|
||||
map.clear();
|
||||
|
||||
let a = quota_user_lock("quota-user-a");
|
||||
let b = quota_user_lock("quota-user-a");
|
||||
assert!(Arc::ptr_eq(&a, &b), "same user must reuse same quota lock");
|
||||
|
||||
Reference in New Issue
Block a user