1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
use std::time::{Duration, Instant};
use autumn_web::auth::hash_password;
use axum::{Router, routing::get, routing::post};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn test_login_material() -> String {
String::from_utf8(
[
97, 116, 116, 97, 99, 107, 101, 114, 95, 112, 97, 115, 115, 119, 111, 114, 100,
]
.to_vec(),
)
.expect("test fixture bytes should be valid utf-8")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn eris_auth_dos_poc() {
// A login route that does heavy bcrypt work.
// In vulnerable code, this function runs synchronously,
// blocking the tokio worker thread.
async fn login_handler() -> &'static str {
let credential = test_login_material();
let _ = hash_password(&credential).await;
"ok"
}
// A fast endpoint that does no heavy work.
async fn ping_handler() -> &'static str {
"pong"
}
let app = Router::new()
.route("/login", post(login_handler))
.route("/ping", get(ping_handler));
// Start server in background
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// We use a simple tcp connection and manually send the HTTP request
// This avoids dependency issues in the tests module.
// We spawn a lot of concurrent hashing tasks to ensure we block the pool.
let mut login_tasks = vec![];
for _ in 0..64 {
login_tasks.push(tokio::spawn(async move {
if let Ok(mut stream) = tokio::net::TcpStream::connect(addr).await {
let req = b"POST /login HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(req).await;
// Wait for the response
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
}
}));
}
// Give the tasks enough time to start and saturate the handlers.
// 1 second is sufficient for all 64 connections to be established and
// for blocking work to hold worker threads (if the implementation is vulnerable).
tokio::time::sleep(Duration::from_secs(1)).await;
// Now ping the server
let ping_start = Instant::now();
let mut ping_success = false;
if let Ok(mut stream) = tokio::net::TcpStream::connect(addr).await {
let req = b"GET /ping HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
if stream.write_all(req).await.is_ok() {
let mut buf = [0u8; 1024];
if stream.read(&mut buf).await.is_ok() {
ping_success = true;
}
}
}
let ping_duration = ping_start.elapsed();
assert!(ping_success, "Ping request failed");
// To prove the fix, we assert that ping returns in a reasonable time.
// A true DoS (synchronous bcrypt blocking the async workers) would make
// this take 30+ seconds, since every worker is held by a hash. The
// non-blocking implementation (spawn_blocking) lets the async workers
// service the ping even while hashes are in flight. The 2s threshold
// leaves slack for constrained CI runners where 64 concurrent bcrypts
// contend for CPU on the blocking threadpool.
assert!(
ping_duration < Duration::from_secs(2),
"Ping took too long ({ping_duration:?}), indicating a Denial of Service via blocked worker threads!"
);
// cleanup
for t in login_tasks {
t.abort();
}
}