198 lines
6.7 KiB
Rust
198 lines
6.7 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,
|
|
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,
|
|
});
|
|
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 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();
|
|
}
|