use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use axum::{
body::Body,
extract::{ws::WebSocket, WebSocketUpgrade},
http::{Request, StatusCode},
response::Response,
routing::{get, post},
Router,
};
use serde_json::{json, Value};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tower::ServiceBuilder;
use tower_http::cors::CorsLayer;
const TEST_PORT: u16 = 8899;
const OVERSIZED_PAYLOAD_MB: usize = 100;
const MAX_WEBSOCKET_FRAME_SIZE: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq)]
enum ThreatType {
UnicodeInvisible { position: usize },
InjectionAttempt { pattern: String },
UnicodeBiDi,
XssAttempt,
}
#[derive(Debug, Clone)]
struct Threat {
threat_type: ThreatType,
severity: String,
location: String,
}
struct SecurityScanner;
impl SecurityScanner {
fn new() -> Self {
SecurityScanner
}
fn scan_text(&self, text: &str) -> Result<Vec<Threat>> {
let mut threats = Vec::new();
if text.contains('\u{202E}') || text.contains('\u{202D}') {
threats.push(Threat {
threat_type: ThreatType::UnicodeBiDi,
severity: "high".to_string(),
location: "text".to_string(),
});
}
if text.contains("'; DROP TABLE") || text.contains("$(") || text.contains("`") {
threats.push(Threat {
threat_type: ThreatType::InjectionAttempt {
pattern: "sql/command injection".to_string(),
},
severity: "critical".to_string(),
location: "text".to_string(),
});
}
if text.contains("<script>") || text.contains("javascript:") {
threats.push(Threat {
threat_type: ThreatType::XssAttempt,
severity: "high".to_string(),
location: "text".to_string(),
});
}
if text.contains('\u{0000}') {
threats.push(Threat {
threat_type: ThreatType::UnicodeInvisible { position: 0 },
severity: "medium".to_string(),
location: "text".to_string(),
});
}
Ok(threats)
}
fn scan_json(&self, json: &Value) -> Result<Vec<Threat>> {
let text = serde_json::to_string(json)?;
self.scan_text(&text)
}
}
#[cfg(test)]
mod http_api_security {
use super::*;
use proptest::prelude::*;
#[tokio::test]
async fn test_http_fuzzing_resistance() -> Result<()> {
let server = create_test_http_server().await?;
let fuzz_payloads = vec![
r#"{"incomplete": "#,
r#"{"nested": {"level": {"too": {"deep": {"for": {"safety": null}}}}}}"#,
r#"{"unicode": "\u0000\u0001\u0002"}"#,
r#"{"bidi": "Hello\u{202E}World"}"#,
"not json at all",
"<xml>also not json</xml>",
"\x00\x01\x02\x03binary data",
r#"{"sql": "'; DROP TABLE users; --"}"#,
r#"{"xss": "<script>alert('xss')</script>"}"#,
r#"{"cmd": "$(rm -rf /)"}"#,
];
for payload in fuzz_payloads {
let response = send_http_request(&server, "/api/scan", payload).await?;
assert!(
response.status() == StatusCode::BAD_REQUEST
|| response.status() == StatusCode::UNPROCESSABLE_ENTITY
|| response.status() == StatusCode::FORBIDDEN,
"Unexpected status for payload: {}",
payload
);
}
Ok(())
}
#[tokio::test]
async fn test_oversized_payload_rejection() -> Result<()> {
let server = create_test_http_server().await?;
let large_string = "x".repeat(OVERSIZED_PAYLOAD_MB * 1024 * 1024);
let payload = json!({
"content": large_string
});
let response =
send_http_request(&server, "/api/scan", &serde_json::to_string(&payload)?).await?;
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
Ok(())
}
#[tokio::test]
async fn test_http_header_injection() -> Result<()> {
let _server = create_test_http_server().await?;
let malicious_headers = vec![
("X-Forwarded-For", "127.0.0.1\r\nX-Admin: true"),
("User-Agent", "Mozilla/5.0\r\nX-Privilege: root"),
(
"Content-Type",
"application/json\r\nX-Bypass-Security: true",
),
];
for (header_name, header_value) in malicious_headers {
let client = reqwest::Client::new();
let response = client
.post(format!("http://localhost:{}/api/scan", TEST_PORT))
.header(header_name, header_value)
.body(r#"{"test": "data"}"#)
.send()
.await?;
assert_ne!(
response.status(),
StatusCode::OK,
"Accepted malicious header: {} = {}",
header_name,
header_value
);
}
Ok(())
}
#[tokio::test]
async fn test_http_method_validation() -> Result<()> {
let _server = create_test_http_server().await?;
let methods = vec!["PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"];
for method in methods {
let client = reqwest::Client::new();
let response = client
.request(
method.parse()?,
format!("http://localhost:{}/api/scan", TEST_PORT),
)
.send()
.await?;
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"Allowed unexpected method: {}",
method
);
}
Ok(())
}
proptest! {
#[test]
fn prop_test_json_fuzzing(s in "\\PC*") {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let server = create_test_http_server().await.unwrap();
let _response = send_http_request(&server, "/api/scan", &s).await;
});
}
}
}
#[cfg(test)]
mod websocket_security {
use super::*;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[tokio::test]
async fn test_websocket_message_tampering() -> Result<()> {
let ws_server = create_test_websocket_server().await?;
let url = format!("ws://localhost:{}/ws", TEST_PORT + 20);
let (mut ws, _) = connect_async(url).await?;
let tampered_messages = vec![
Message::Binary(vec![0u8; MAX_WEBSOCKET_FRAME_SIZE + 1]),
Message::Text("{broken json".to_string()),
Message::Text(r#"{"type":"data","content":"test\x00\x01\x02"}"#.to_string()),
Message::Text(r#"{"content":"Hello\u{202E}World"}"#.to_string()),
];
for msg in tampered_messages {
let _ = ws.send(msg).await;
if let Some(Ok(response)) = ws.next().await {
match response {
Message::Text(text) => {
assert!(
text.contains("error") || text.contains("invalid"),
"WebSocket accepted tampered message"
);
},
Message::Close(_) => {
break;
},
_ => {},
}
}
}
Ok(())
}
#[tokio::test]
async fn test_websocket_dos_protection() -> Result<()> {
let ws_server = create_test_websocket_server().await?;
let mut handles = vec![];
for i in 0..50 {
let server = ws_server.clone();
let handle = tokio::spawn(async move {
let url = format!("ws://localhost:{}/ws", TEST_PORT + 20);
match connect_async(url).await {
Ok((mut ws, _)) => {
let _ = ws.send(Message::Text(format!("spam {}", i))).await;
let _ = ws.close(None).await;
},
Err(_) => {
},
}
});
handles.push(handle);
}
for handle in handles {
let _ = handle.await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
let url = format!("ws://localhost:{}/ws", TEST_PORT + 20);
let result = tokio::time::timeout(Duration::from_secs(5), connect_async(url)).await;
assert!(
result.is_ok(),
"WebSocket server unresponsive after DoS attempt"
);
Ok(())
}
}
#[cfg(test)]
mod protocol_injection_attacks {
use super::*;
#[tokio::test]
async fn test_stdio_command_injection() -> Result<()> {
let scanner = SecurityScanner::new();
let stdio_attacks = vec![
r#"{"method": "scan", "params": {"text": "$(cat /etc/passwd)"}}"#,
r#"{"method": "scan", "params": {"text": "`rm -rf /`"}}"#,
r#"{"method": "scan", "params": {"text": "text; echo 'pwned'"}}"#,
r#"{"method": "readFile", "params": {"path": "../../../etc/passwd"}}"#,
r#"{"method": "readFile", "params": {"path": "/etc/passwd"}}"#,
r#"{"method": "exec", "params": {"cmd": "bash", "args": ["-c", "evil"]}}"#,
];
for attack in stdio_attacks {
let threats = scanner.scan_text(attack)?;
assert!(
!threats.is_empty(),
"Failed to detect stdio injection: {}",
attack
);
}
Ok(())
}
#[tokio::test]
async fn test_multipart_form_injection() -> Result<()> {
let _server = create_test_http_server().await?;
let boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW";
let malicious_multipart = format!(
"--{}\r\n\
Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\r\n\r\n\
normal content\r\n\
--{}\r\n\
Content-Disposition: form-data; name=\"description\"\r\n\r\n\
<script>alert('xss')</script>\r\n\
--{}\r\n\
Content-Disposition: form-data; name=\"../../../etc/passwd\"\r\n\r\n\
attempted path traversal\r\n\
--{}--",
boundary, boundary, boundary, boundary
);
let client = reqwest::Client::new();
let response = client
.post(format!("http://localhost:{}/api/upload", TEST_PORT))
.header(
"Content-Type",
format!("multipart/form-data; boundary={}", boundary),
)
.body(malicious_multipart)
.send()
.await?;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"Failed to detect multipart injection"
);
Ok(())
}
}
#[cfg(test)]
mod cross_protocol_attacks {
use super::*;
#[tokio::test]
async fn test_protocol_smuggling() -> Result<()> {
let _server = create_test_http_server().await?;
let smuggled_payload = vec![
0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58, ];
let client = reqwest::Client::new();
let response = client
.post(format!("http://localhost:{}/api/scan", TEST_PORT))
.header("Content-Type", "application/octet-stream")
.body(smuggled_payload)
.send()
.await?;
assert_ne!(
response.status(),
StatusCode::OK,
"Server processed smuggled WebSocket frame"
);
Ok(())
}
#[tokio::test]
async fn test_timing_attack_protection() -> Result<()> {
let scanner = SecurityScanner::new();
let medium_input = "a".repeat(100);
let long_input = "a".repeat(1000);
let test_inputs = vec![
("short", "abc"),
("medium", medium_input.as_str()),
("long", long_input.as_str()),
("with_threat", "normal text <script>alert('xss')</script>"),
];
let mut timings = vec![];
for (name, input) in test_inputs {
let start = std::time::Instant::now();
let _ = scanner.scan_text(input);
let duration = start.elapsed();
timings.push((name, duration));
}
let max_duration = timings.iter().map(|(_, d)| d).max().unwrap();
let min_duration = timings.iter().map(|(_, d)| d).min().unwrap();
let variance = max_duration.as_micros() as f64 / min_duration.as_micros() as f64;
assert!(
variance < 10.0,
"Excessive timing variance detected: {:.2}x",
variance
);
Ok(())
}
}
async fn create_test_http_server() -> Result<Arc<TestServer>> {
let scanner = Arc::new(SecurityScanner::new());
let app = Router::new()
.route("/api/scan", post(scan_handler))
.route("/api/upload", post(upload_handler))
.layer(
ServiceBuilder::new()
.layer(CorsLayer::permissive())
.into_inner(),
)
.with_state(scanner.clone());
let listener = TcpListener::bind(format!("127.0.0.1:{}", TEST_PORT)).await?;
let server = Arc::new(TestServer {
address: listener.local_addr()?,
scanner,
});
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(server)
}
async fn create_test_websocket_server() -> Result<Arc<TestWebSocketServer>> {
let app = Router::new().route("/ws", get(websocket_handler));
let listener = TcpListener::bind(format!("127.0.0.1:{}", TEST_PORT + 20)).await?;
let server = Arc::new(TestWebSocketServer {
port: TEST_PORT + 20,
connections: Arc::new(Mutex::new(vec![])),
});
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(server)
}
async fn scan_handler(
axum::extract::State(scanner): axum::extract::State<Arc<SecurityScanner>>,
body: String,
) -> Result<Response<Body>, StatusCode> {
if body.len() > 10 * 1024 * 1024 {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
let json_result: Result<Value, _> = serde_json::from_str(&body);
match json_result {
Ok(json) => {
let threats = scanner
.scan_json(&json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !threats.is_empty() {
return Err(StatusCode::FORBIDDEN);
}
Ok(Response::new(Body::from(
json!({"status": "clean"}).to_string(),
)))
},
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
async fn upload_handler(body: String) -> Result<Response<Body>, StatusCode> {
if body.contains("<script>") || body.contains("../") {
return Err(StatusCode::BAD_REQUEST);
}
Ok(Response::new(Body::from(
json!({"status": "uploaded"}).to_string(),
)))
}
async fn websocket_handler(ws: WebSocketUpgrade) -> Response<Body> {
ws.on_upgrade(handle_websocket)
}
async fn handle_websocket(mut socket: WebSocket) {
let scanner = SecurityScanner::new();
while let Some(msg) = socket.recv().await {
match msg {
Ok(axum::extract::ws::Message::Text(text)) => {
if let Ok(threats) = scanner.scan_text(&text) {
if !threats.is_empty() {
let _ = socket
.send(axum::extract::ws::Message::Text(
json!({"error": "threat detected"}).to_string(),
))
.await;
break;
}
}
let _ = socket.send(axum::extract::ws::Message::Text(text)).await;
},
Ok(axum::extract::ws::Message::Binary(data)) => {
if data.len() > MAX_WEBSOCKET_FRAME_SIZE {
let _ = socket
.send(axum::extract::ws::Message::Text(
json!({"error": "frame too large"}).to_string(),
))
.await;
break;
}
},
Ok(axum::extract::ws::Message::Close(_)) => break,
Err(_) => break,
_ => {},
}
}
}
async fn send_http_request(
_server: &Arc<TestServer>,
path: &str,
body: &str,
) -> Result<reqwest::Response> {
let client = reqwest::Client::new();
Ok(client
.post(format!("http://localhost:{}{}", TEST_PORT, path))
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.await?)
}
struct TestServer {
address: std::net::SocketAddr,
scanner: Arc<SecurityScanner>,
}
struct TestWebSocketServer {
port: u16,
connections: Arc<Mutex<Vec<String>>>,
}