Files
xevion.dev/src/tarpit.rs
Xevion c6dd1dffb0 feat: add cookie-based session authentication system
- Add admin user management with Argon2 password hashing
- Implement session management with ULID-based tokens and 7-day expiry
- Add authentication middleware for protected routes and API endpoints
- Forward validated session to SvelteKit via trusted X-Session-User header
- Refactor admin panel to use server-side authentication checks
2026-01-06 11:33:38 -06:00

547 lines
17 KiB
Rust

use axum::{
body::Body,
extract::{ConnectInfo, Request, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use dashmap::DashMap;
use futures::stream::{self, Stream};
use rand::Rng;
use std::net::{IpAddr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::time::Instant;
#[derive(Debug, Clone)]
pub struct TarpitConfig {
pub enabled: bool,
pub delay_min_ms: u64,
pub delay_max_ms: u64,
pub chunk_size_min: usize,
pub chunk_size_max: usize,
pub max_global_connections: usize,
pub max_connections_per_ip: usize,
}
impl TarpitConfig {
pub fn from_env() -> Self {
Self {
enabled: std::env::var("TARPIT_ENABLED")
.map(|v| v == "true" || v == "1")
.unwrap_or(true),
delay_min_ms: std::env::var("TARPIT_DELAY_MIN_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100),
delay_max_ms: std::env::var("TARPIT_DELAY_MAX_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500),
chunk_size_min: std::env::var("TARPIT_CHUNK_MIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(64),
chunk_size_max: std::env::var("TARPIT_CHUNK_MAX")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1024),
max_global_connections: std::env::var("TARPIT_MAX_GLOBAL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
max_connections_per_ip: std::env::var("TARPIT_MAX_PER_IP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100),
}
}
}
pub struct TarpitState {
global_semaphore: Arc<Semaphore>,
ip_connections: Arc<DashMap<IpAddr, Arc<Semaphore>>>,
pub config: Arc<TarpitConfig>,
}
impl TarpitState {
pub fn new(config: TarpitConfig) -> Self {
let config = Arc::new(config);
Self {
global_semaphore: Arc::new(Semaphore::new(config.max_global_connections)),
ip_connections: Arc::new(DashMap::new()),
config,
}
}
}
#[derive(Debug, Clone, Copy)]
enum ResponseMode {
RandomBytes,
FakeHtml,
FakeJson,
}
impl ResponseMode {
fn random() -> Self {
let mut rng = rand::rng();
match rng.random_range(0..3) {
0 => Self::RandomBytes,
1 => Self::FakeHtml,
_ => Self::FakeJson,
}
}
fn content_type(&self) -> &'static str {
match self {
Self::RandomBytes => "application/octet-stream",
Self::FakeHtml => "text/html; charset=utf-8",
Self::FakeJson => "application/json",
}
}
}
pub fn is_malicious_path(path: &str) -> bool {
let path_lower = path.to_lowercase();
// File extension checks
if path_lower.ends_with(".php")
|| path_lower.ends_with(".asp")
|| path_lower.ends_with(".aspx")
|| path_lower.ends_with(".sql")
|| path_lower.ends_with(".zip")
|| path_lower.ends_with(".tar")
|| path_lower.ends_with(".tar.gz")
|| path_lower.ends_with(".rar")
|| path_lower.ends_with(".backup")
{
return true;
}
// WordPress paths
if path_lower.starts_with("/wp-") || path_lower.starts_with("/wordpress/") {
return true;
}
// Admin panels
if path_lower.starts_with("/administrator") || path_lower.contains("phpmyadmin") {
return true;
}
// Config and credential files
if path_lower.starts_with("/.env")
|| path_lower.contains("/config.")
|| path_lower.contains("/.git/")
|| path_lower.contains("/.svn/")
|| path_lower.contains("/.hg/")
|| path_lower.contains("/.bzr/")
|| path_lower.contains("/credentials")
|| path_lower.contains("service-account.json")
|| path_lower.contains("firebase")
|| path_lower.contains("/.aws/")
|| path_lower.contains("/.kube/")
{
return true;
}
// CGI and old web tech
if path_lower.starts_with("/cgi-bin/") {
return true;
}
// Spring Boot actuators
if path_lower.starts_with("/actuator") {
return true;
}
// API documentation/explorers
if path_lower.starts_with("/api-docs")
|| path_lower.starts_with("/swagger")
|| path_lower.starts_with("/graphql")
|| path_lower.starts_with("/graphiql")
|| path_lower.starts_with("/playground")
{
return true;
}
// Infrastructure files
if path_lower.contains("/terraform.")
|| path_lower.contains("dockerfile")
|| path_lower.contains("docker-compose")
|| path_lower.contains("/backup")
{
return true;
}
// Package manager files (except those we might legitimately serve)
if path_lower.contains("composer.json")
|| path_lower.contains("composer.lock")
|| path_lower.contains("gemfile")
|| path_lower.contains("pipfile")
{
return true;
}
false
}
pub fn extract_client_ip(headers: &HeaderMap, peer_addr: Option<SocketAddr>) -> IpAddr {
// Check X-Real-IP first (Railway sets this)
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(ip_str) = real_ip.to_str()
&& let Ok(ip) = ip_str.parse()
{
return ip;
}
// Fallback to X-Forwarded-For (take first IP)
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(forwarded_str) = forwarded.to_str()
&& let Some(first_ip) = forwarded_str.split(',').next()
&& let Ok(ip) = first_ip.trim().parse()
{
return ip;
}
// Fallback to peer address from connection
peer_addr.map_or_else(
|| {
tracing::warn!("No peer address available, defaulting to localhost");
"127.0.0.1".parse().expect("hardcoded IP should parse")
},
|addr| addr.ip(),
)
}
type BoxedByteStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, std::io::Error>> + Send>>;
fn create_random_bytes_stream(config: Arc<TarpitConfig>) -> BoxedByteStream {
Box::pin(stream::unfold((), move |()| {
let config = Arc::clone(&config);
async move {
let (delay_ms, chunk) = {
let mut rng = rand::rng();
let delay_ms = rng.random_range(config.delay_min_ms..=config.delay_max_ms);
let chunk_size = rng.random_range(config.chunk_size_min..=config.chunk_size_max);
let chunk: Vec<u8> = (0..chunk_size).map(|_| rng.random()).collect();
(delay_ms, chunk)
};
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
Some((Ok(chunk), ()))
}
}))
}
fn create_fake_html_stream(config: Arc<TarpitConfig>) -> BoxedByteStream {
Box::pin(stream::unfold(0, move |counter| {
let config = Arc::clone(&config);
async move {
let (delay_ms, chunk) = {
let mut rng = rand::rng();
let delay_ms = rng.random_range(config.delay_min_ms..=config.delay_max_ms);
let chunk = if counter == 0 {
concat!(
"<!DOCTYPE html>\n",
"<html>\n",
"<head>\n",
" <title>Admin Panel</title>\n",
" <meta charset=\"utf-8\">\n",
"</head>\n",
"<body>\n",
" <h1>Loading...</h1>\n",
" <div class=\"content\">\n"
)
.as_bytes()
.to_vec()
} else {
let elements = [
" <div class=\"item\">Processing request...</div>\n",
" <span class=\"status\">Initializing...</span>\n",
" <!-- Loading data -->\n",
" <p>Fetching records...</p>\n",
" <div class=\"loader\"></div>\n",
" <script>console.log('Loading...');</script>\n",
];
let element = elements[rng.random_range(0..elements.len())];
element.as_bytes().to_vec()
};
(delay_ms, chunk)
};
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
Some((Ok(chunk), counter + 1))
}
}))
}
fn create_fake_json_stream(config: Arc<TarpitConfig>) -> BoxedByteStream {
Box::pin(stream::unfold(0, move |counter| {
let config = Arc::clone(&config);
async move {
let (delay_ms, chunk) = {
let mut rng = rand::rng();
let delay_ms = rng.random_range(config.delay_min_ms..=config.delay_max_ms);
let chunk = if counter == 0 {
b"{\"status\":\"success\",\"data\":[\n".to_vec()
} else {
let id = counter;
let username = format!("user{}", rng.random_range(1000..9999));
let email = format!("{username}@example.com");
let json = format!(
"{{\"id\":{id},\"username\":\"{username}\",\"email\":\"{email}\",\"active\":true}},\n"
);
json.as_bytes().to_vec()
};
(delay_ms, chunk)
};
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
Some((Ok(chunk), counter + 1))
}
}))
}
pub async fn tarpit_handler(
State(state): State<Arc<TarpitState>>,
peer: Option<ConnectInfo<SocketAddr>>,
req: Request,
) -> Response {
let path = req.uri().path().to_string();
let headers = req.headers();
let client_ip = extract_client_ip(headers, peer.map(|ConnectInfo(addr)| addr));
// Try to acquire global semaphore
let _global_permit = if let Ok(Ok(permit)) = tokio::time::timeout(
Duration::from_millis(100),
state.global_semaphore.clone().acquire_owned(),
)
.await
{
permit
} else {
tracing::debug!(
client_ip = %client_ip,
reason = "global_limit",
"Tarpit connection rejected"
);
return (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable").into_response();
};
// Get or create per-IP semaphore
let ip_semaphore = state
.ip_connections
.entry(client_ip)
.or_insert_with(|| Arc::new(Semaphore::new(state.config.max_connections_per_ip)))
.clone();
// Try to acquire per-IP semaphore
let _ip_permit = if let Ok(Ok(permit)) = tokio::time::timeout(
Duration::from_millis(100),
ip_semaphore.clone().acquire_owned(),
)
.await
{
permit
} else {
tracing::debug!(
client_ip = %client_ip,
reason = "ip_limit",
"Tarpit connection rejected"
);
return (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable").into_response();
};
let mode = ResponseMode::random();
let start = Instant::now();
tracing::debug!(
path = %path,
client_ip = %client_ip,
mode = ?mode,
global_available = state.global_semaphore.available_permits(),
ip_available = ip_semaphore.available_permits(),
"Tarpit triggered"
);
let stream: BoxedByteStream = match mode {
ResponseMode::RandomBytes => create_random_bytes_stream(Arc::clone(&state.config)),
ResponseMode::FakeHtml => create_fake_html_stream(Arc::clone(&state.config)),
ResponseMode::FakeJson => create_fake_json_stream(Arc::clone(&state.config)),
};
// Wrap stream to log on drop and hold permits
let stream_with_logging = stream::unfold(
(
stream,
start,
client_ip,
0usize,
false,
_global_permit,
_ip_permit,
),
|(mut stream, start, client_ip, bytes_sent, logged, global_permit, ip_permit)| async move {
use futures::StreamExt;
match stream.next().await {
Some(Ok(chunk)) => {
let new_bytes = bytes_sent + chunk.len();
Some((
Ok(chunk),
(
stream,
start,
client_ip,
new_bytes,
logged,
global_permit,
ip_permit,
),
))
}
Some(Err(e)) => Some((
Err(e),
(
stream,
start,
client_ip,
bytes_sent,
logged,
global_permit,
ip_permit,
),
)),
None => {
if !logged {
let duration = start.elapsed();
tracing::debug!(
client_ip = %client_ip,
duration_secs = duration.as_secs(),
bytes_sent,
"Tarpit connection closed"
);
}
None
}
}
},
);
let body = Body::from_stream(stream_with_logging);
let mut response = Response::new(body);
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
mode.content_type()
.parse()
.expect("content type should be valid header value"),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_php_files() {
assert!(is_malicious_path("/admin.php"));
assert!(is_malicious_path("/wp-login.php"));
assert!(is_malicious_path("/index.php"));
assert!(is_malicious_path("/INFO.PHP"));
}
#[test]
fn test_wordpress_paths() {
assert!(is_malicious_path("/wp-admin/"));
assert!(is_malicious_path("/wp-content/plugins/"));
assert!(is_malicious_path("/wp-includes/"));
assert!(is_malicious_path("/wordpress/index.php"));
}
#[test]
fn test_admin_panels() {
assert!(!is_malicious_path("/admin"));
assert!(is_malicious_path("/administrator"));
assert!(is_malicious_path("/phpmyadmin"));
assert!(is_malicious_path("/phpMyAdmin"));
}
#[test]
fn test_config_files() {
assert!(is_malicious_path("/.env"));
assert!(is_malicious_path("/.git/config"));
assert!(is_malicious_path("/config.php"));
assert!(is_malicious_path("/.aws/credentials"));
}
#[test]
fn test_actuator_endpoints() {
assert!(is_malicious_path("/actuator"));
assert!(is_malicious_path("/actuator/health"));
}
#[test]
fn test_api_docs() {
assert!(is_malicious_path("/swagger.json"));
assert!(is_malicious_path("/graphql"));
assert!(is_malicious_path("/api-docs"));
}
#[test]
fn test_legitimate_paths() {
assert!(!is_malicious_path("/"));
assert!(!is_malicious_path("/about"));
assert!(!is_malicious_path("/api/projects"));
assert!(!is_malicious_path("/favicon.ico"));
assert!(!is_malicious_path("/robots.txt"));
assert!(!is_malicious_path("/sitemap.xml"));
assert!(!is_malicious_path("/keybase.txt"));
assert!(!is_malicious_path("/_app/some-asset.js"));
}
#[test]
fn test_ip_extraction() {
use std::net::SocketAddr;
let mut headers = HeaderMap::new();
let peer: SocketAddr = "192.0.2.50:12345".parse().unwrap();
// Test X-Real-IP
headers.insert("x-real-ip", "203.0.113.42".parse().unwrap());
let ip = extract_client_ip(&headers, Some(peer));
assert_eq!(ip, "203.0.113.42".parse::<IpAddr>().unwrap());
// Test X-Forwarded-For
headers.clear();
headers.insert(
"x-forwarded-for",
"198.51.100.1, 192.0.2.1".parse().unwrap(),
);
let ip = extract_client_ip(&headers, Some(peer));
assert_eq!(ip, "198.51.100.1".parse::<IpAddr>().unwrap());
// Test X-Real-IP takes precedence
headers.insert("x-real-ip", "203.0.113.100".parse().unwrap());
let ip = extract_client_ip(&headers, Some(peer));
assert_eq!(ip, "203.0.113.100".parse::<IpAddr>().unwrap());
// Test fallback to peer address
headers.clear();
let ip = extract_client_ip(&headers, Some(peer));
assert_eq!(ip, "192.0.2.50".parse::<IpAddr>().unwrap());
// Test fallback to localhost when no peer
let ip = extract_client_ip(&headers, None);
assert_eq!(ip, "127.0.0.1".parse::<IpAddr>().unwrap());
}
}