Files
mcp/tests/http_security.rs
2026-08-11 02:02:39 +08:00

233 lines
7.9 KiB
Rust

use axum::{
extract::ConnectInfo,
http::{header, Request, StatusCode},
};
use futures_util::StreamExt;
use ipnet::IpNet;
use minecraft_log_viewer::{
auth::{client_ip::ClientIpPolicy, whitelist::Whitelist, AuthorizationService},
config::Config,
http::{router, AppState},
logs::index::{HistoryIndex, HistoryStore},
};
use std::{
net::SocketAddr,
sync::{atomic::AtomicUsize, Arc},
};
use tokio::sync::broadcast;
use tower::ServiceExt;
async fn app() -> (
axum::Router,
tempfile::TempDir,
broadcast::Sender<minecraft_log_viewer::logs::follower::LiveMessage>,
) {
let dir = tempfile::tempdir().unwrap();
let logs = dir.path().join("logs");
std::fs::create_dir(&logs).unwrap();
let latest = logs.join("latest.log");
std::fs::write(&latest, "[12:00:00] Alex[/203.0.113.4:51234] logged in\n").unwrap();
let whitelist_path = dir.path().join("whitelist.json");
let whitelist_json = br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#;
std::fs::write(&whitelist_path, whitelist_json).unwrap();
let config = Arc::new(Config {
listen_addr: "127.0.0.1:0".parse().unwrap(),
data_dir: dir.path().to_path_buf(),
log_dir: logs.clone(),
latest_log: latest.clone(),
whitelist: whitelist_path,
ip_whitelist_file: Some(dir.path().join("ip-whitelist.txt")),
trust_proxy: true,
trusted_proxy_cidrs: vec![
"10.0.0.0/8".parse().unwrap(),
"127.0.0.1/32".parse().unwrap(),
],
client_ip_header: "x-forwarded-for".into(),
public_origin: "https://logs.example".into(),
initial_log_lines: 100,
max_history_lines: 100,
archive_cache_max_bytes: 1024,
archive_cache_max_files: 2,
max_archive_bytes: 4096,
ws_queue_capacity: 8,
max_ws_connections: 2,
redact_player_ips: true,
});
std::fs::write(dir.path().join("ip-whitelist.txt"), "").unwrap();
let auth = AuthorizationService::new(latest, Whitelist::parse(whitelist_json).unwrap());
auth.rebuild().await.unwrap();
let history = HistoryStore::new(HistoryIndex::scan(&logs).unwrap(), 1024, 2, 4096, true);
let (live_tx, _) = broadcast::channel(8);
let state = AppState {
config,
auth,
history,
live_tx: live_tx.clone(),
ip_policy: ClientIpPolicy {
trust_proxy: true,
trusted_proxies: vec![
"10.0.0.0/8".parse::<IpNet>().unwrap(),
"127.0.0.1/32".parse().unwrap(),
],
header: "x-forwarded-for".into(),
},
ws_count: Arc::new(AtomicUsize::new(0)),
};
(router(state), dir, live_tx)
}
fn request(peer: &str) -> Request<axum::body::Body> {
let mut request = Request::builder()
.uri("/api/logs/recent")
.body(axum::body::Body::empty())
.unwrap();
request
.extensions_mut()
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
request
}
#[tokio::test]
async fn frontend_security_policy_allows_its_external_assets() {
let (app, _dir, _tx) = app().await;
let mut request = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
request.extensions_mut().insert(ConnectInfo(
"203.0.113.4:5000".parse::<SocketAddr>().unwrap(),
));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()[header::CONTENT_SECURITY_POLICY],
"default-src 'self'; connect-src 'self' ws: wss:; style-src 'self'; script-src 'self'; frame-ancestors 'none'"
);
let body = axum::body::to_bytes(response.into_body(), 8192)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("rel=\"stylesheet\""));
assert!(!html.contains("<style"));
}
#[tokio::test]
async fn authorized_direct_ip_receives_redacted_logs() {
let (app, _dir, _tx) = app().await;
let response = app.oneshot(request("203.0.113.4:5000")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 8192)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("IP REDACTED"));
assert!(!text.contains("203.0.113.4"));
}
#[tokio::test]
async fn untrusted_peer_cannot_spoof_an_authorized_forwarded_ip() {
let (app, _dir, _tx) = app().await;
let mut request = request("192.0.2.9:5000");
request
.headers_mut()
.insert("x-forwarded-for", "203.0.113.4".parse().unwrap());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn denied_response_reports_the_ip_used_for_authorization() {
let (app, _dir, _tx) = app().await;
let mut request = request("127.0.0.1:5000");
request
.headers_mut()
.insert("x-forwarded-for", "100.64.0.27".parse().unwrap());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = axum::body::to_bytes(response.into_body(), 8192)
.await
.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["client_ip"],
"100.64.0.27"
);
}
#[tokio::test]
async fn owner_ip_whitelist_is_a_hot_reloaded_final_fallback() {
let (app, dir, _tx) = app().await;
let peer = "100.64.0.27:5000";
assert_eq!(
app.clone().oneshot(request(peer)).await.unwrap().status(),
StatusCode::FORBIDDEN
);
std::fs::write(dir.path().join("ip-whitelist.txt"), "100.64.0.27\n").unwrap();
assert_eq!(
app.oneshot(request(peer)).await.unwrap().status(),
StatusCode::OK
);
}
#[tokio::test]
async fn unexpected_browser_origin_is_rejected() {
let (app, _dir, _tx) = app().await;
let mut request = request("203.0.113.4:5000");
request
.headers_mut()
.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn authorized_websocket_receives_ordered_batches() {
use minecraft_log_viewer::logs::{follower::LiveMessage, LogLine};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let (app, _dir, tx) = app().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
});
let mut request = format!("ws://{address}/api/live")
.into_client_request()
.unwrap();
request
.headers_mut()
.insert(header::ORIGIN, "https://logs.example".parse().unwrap());
request
.headers_mut()
.insert("x-forwarded-for", "203.0.113.4".parse().unwrap());
let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap();
let hello = socket.next().await.unwrap().unwrap().into_text().unwrap();
assert!(hello.contains("hello"));
tx.send(LiveMessage::LogLines {
lines: vec![
LogLine {
id: "1".into(),
timestamp: None,
text: "first".into(),
source: "latest.log".into(),
},
LogLine {
id: "2".into(),
timestamp: None,
text: "second".into(),
source: "latest.log".into(),
},
],
})
.unwrap();
let batch = socket.next().await.unwrap().unwrap().into_text().unwrap();
assert!(batch.find("first").unwrap() < batch.find("second").unwrap());
server.abort();
}