use crate::server::jobs::JobManager;
use crate::server::models::{FilterValues, FindingSummary, FindingView};
use crate::server::observability;
use crate::server::progress::TimingBreakdown;
use crate::server::routes;
use crate::server::security::LocalServerSecurity;
use crate::utils::config::Config;
use axum::Router;
use parking_lot::RwLock;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::broadcast;
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type", content = "data")]
pub enum ServerEvent {
ScanStarted {
job_id: String,
},
ScanCompleted {
job_id: String,
},
ScanFailed {
job_id: String,
error: String,
},
ScanProgress {
job_id: String,
stage: String,
files_discovered: u64,
files_parsed: u64,
files_analyzed: u64,
files_skipped: u64,
batches_total: u64,
batches_completed: u64,
current_file: String,
elapsed_ms: u64,
timing: TimingBreakdown,
},
ConfigChanged,
}
#[derive(Debug, Clone)]
pub struct CachedFindings {
pub job_id: String,
pub views: Arc<Vec<FindingView>>,
pub summary: Arc<FindingSummary>,
pub filters: Arc<FilterValues>,
}
#[derive(Clone)]
pub struct AppState {
pub scan_root: PathBuf,
pub config_dir: PathBuf,
pub database_dir: PathBuf,
pub security: Arc<LocalServerSecurity>,
pub config: Arc<RwLock<Config>>,
pub job_manager: Arc<JobManager>,
pub event_tx: broadcast::Sender<ServerEvent>,
pub db_pool: Option<Arc<Pool<SqliteConnectionManager>>>,
pub findings_cache: Arc<RwLock<Option<CachedFindings>>>,
}
const MAX_BODY_BYTES: usize = 50 * 1024 * 1024;
const CSP: &str = "default-src 'self'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; \
connect-src 'self'";
pub fn build_router(state: AppState) -> Router {
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderName, HeaderValue, header};
use axum::middleware;
use tower_http::compression::CompressionLayer;
use tower_http::set_header::SetResponseHeaderLayer;
let security = Arc::clone(&state.security);
Router::new()
.nest("/api", routes::api_routes())
.fallback(crate::server::assets::static_handler)
.layer(middleware::from_fn_with_state(
security,
crate::server::security::guard_requests,
))
.layer(middleware::from_fn(observability::observe))
.layer(CompressionLayer::new())
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::overriding(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
))
.layer(SetResponseHeaderLayer::overriding(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(CSP),
))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tower::util::ServiceExt;
fn test_state(scan_root: PathBuf, port: u16) -> AppState {
let (event_tx, _) = broadcast::channel(8);
AppState {
scan_root: scan_root.clone(),
config_dir: scan_root.clone(),
database_dir: scan_root.clone(),
security: LocalServerSecurity::new(port),
config: Arc::new(RwLock::new(Config::default())),
job_manager: Arc::new(JobManager::new(4, 8 * 1024 * 1024)),
event_tx,
db_pool: None,
findings_cache: Arc::new(RwLock::new(None)),
}
}
async fn session_token(state: &AppState) -> String {
let response = build_router(state.clone())
.oneshot(
Request::builder()
.uri("/api/session")
.header("host", "localhost:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
payload["csrf_token"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn rejects_bad_host_headers() {
let dir = tempfile::tempdir().unwrap();
let app = build_router(test_state(dir.path().to_path_buf(), 9700));
let response = app
.oneshot(
Request::builder()
.uri("/api/health")
.header("host", "evil.example:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn blocks_mutations_without_csrf_token() {
let dir = tempfile::tempdir().unwrap();
let app = build_router(test_state(dir.path().to_path_buf(), 9700));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/scans")
.header("host", "localhost:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn blocks_cross_origin_mutations_even_with_csrf_token() {
let dir = tempfile::tempdir().unwrap();
let state = test_state(dir.path().to_path_buf(), 9700);
let token = session_token(&state).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/scans")
.header("host", "localhost:9700")
.header("origin", "http://evil.example:9700")
.header("x-nyx-csrf", token)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn rejects_traversal_in_file_route() {
let dir = tempfile::tempdir().unwrap();
let app = build_router(test_state(dir.path().to_path_buf(), 9700));
let response = app
.oneshot(
Request::builder()
.uri("/api/files?path=..%2Fsecret.txt")
.header("host", "localhost:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn security_headers_present_on_response() {
let dir = tempfile::tempdir().unwrap();
let app = build_router(test_state(dir.path().to_path_buf(), 9700));
let response = app
.oneshot(
Request::builder()
.uri("/api/health")
.header("host", "localhost:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let headers = response.headers();
assert_eq!(
headers.get("x-frame-options").and_then(|v| v.to_str().ok()),
Some("DENY"),
);
assert_eq!(
headers
.get("x-content-type-options")
.and_then(|v| v.to_str().ok()),
Some("nosniff"),
);
assert_eq!(
headers.get("referrer-policy").and_then(|v| v.to_str().ok()),
Some("no-referrer"),
);
let csp = headers
.get("content-security-policy")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(csp.contains("default-src 'self'"), "CSP was: {csp}");
assert!(csp.contains("script-src 'self'"), "CSP was: {csp}");
}
#[tokio::test]
async fn config_lock_survives_panic_in_write_guard() {
let dir = tempfile::tempdir().unwrap();
let state = test_state(dir.path().to_path_buf(), 9700);
let lock = Arc::clone(&state.config);
let join = std::thread::spawn(move || {
let _guard = lock.write();
panic!("simulated handler panic while holding write lock");
});
assert!(join.join().is_err(), "worker thread was expected to panic");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/config")
.header("host", "localhost:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[cfg(unix)]
#[tokio::test]
async fn explorer_tree_skips_symlink_escapes() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("secret.rs");
std::fs::write(&outside_file, "fn leaked() {}").unwrap();
symlink(&outside_file, dir.path().join("escape.rs")).unwrap();
let response = build_router(test_state(dir.path().to_path_buf(), 9700))
.oneshot(
Request::builder()
.uri("/api/explorer/tree")
.header("host", "localhost:9700")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let entries = payload.as_array().unwrap();
assert!(entries.iter().all(|entry| entry["name"] != "escape.rs"));
}
}