feat(server): servir dist same-origin + durcissement (logout, logs sécurité, static hardening) (#13)
Sert le bundle web (dist/) en same-origin via --web-root / IDEA_WEB_ROOT ou le web/ packagé. serve_static durci : anti path-traversal, typage MIME, X-Content-Type-Options nosniff, CSP, fallback SPA. Ajoute POST /api/logout qui révoque la session HTTP + WS, et une journalisation sécurité via SecurityLogger. Documente le déploiement remote (dev local, packaging web/, reverse proxy HTTPS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@ -95,6 +95,7 @@ struct ServerConfig {
|
||||
allow_remote: bool,
|
||||
trust_reverse_proxy: bool,
|
||||
app_data_dir: PathBuf,
|
||||
web_root: PathBuf,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
@ -106,6 +107,7 @@ impl ServerConfig {
|
||||
let mut allow_remote = false;
|
||||
let mut trust_reverse_proxy = false;
|
||||
let mut app_data_dir = default_app_data_dir();
|
||||
let mut web_root = None;
|
||||
|
||||
let mut it = args.into_iter();
|
||||
while let Some(arg) = it.next() {
|
||||
@ -132,10 +134,17 @@ impl ServerConfig {
|
||||
.ok_or_else(|| "--app-data-dir requires a path".to_owned())?;
|
||||
app_data_dir = PathBuf::from(value);
|
||||
}
|
||||
"--web-root" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| "--web-root requires a path".to_owned())?;
|
||||
web_root = Some(PathBuf::from(value));
|
||||
}
|
||||
"--help" | "-h" => return Err(Self::usage()),
|
||||
other => return Err(format!("unknown --serve argument: {other}")),
|
||||
}
|
||||
}
|
||||
let web_root = resolve_web_root(web_root)?;
|
||||
|
||||
Ok(Self {
|
||||
listen,
|
||||
@ -143,6 +152,7 @@ impl ServerConfig {
|
||||
allow_remote,
|
||||
trust_reverse_proxy,
|
||||
app_data_dir,
|
||||
web_root,
|
||||
})
|
||||
}
|
||||
|
||||
@ -182,7 +192,46 @@ impl ServerConfig {
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned()
|
||||
"usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
|
||||
if let Some(path) = explicit {
|
||||
return validate_web_root(path, "--web-root");
|
||||
}
|
||||
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
|
||||
return validate_web_root(path, "IDEA_WEB_ROOT");
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("web"));
|
||||
candidates.push(dir.join("frontend").join("dist"));
|
||||
}
|
||||
}
|
||||
if let Ok(cwd) = env::current_dir() {
|
||||
candidates.push(cwd.join("frontend").join("dist"));
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
if candidate.join("index.html").is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned())
|
||||
}
|
||||
|
||||
fn validate_web_root(path: PathBuf, source: &str) -> Result<PathBuf, String> {
|
||||
if path.join("index.html").is_file() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(format!(
|
||||
"{source} does not contain an index.html: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,6 +241,7 @@ struct ServerState {
|
||||
pairing_code: String,
|
||||
sessions: Mutex<HashSet<String>>,
|
||||
ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>,
|
||||
security_logger: Arc<dyn SecurityLogger>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
@ -202,17 +252,28 @@ impl ServerState {
|
||||
pairing_code: new_pairing_code(),
|
||||
sessions: Mutex::new(HashSet::new()),
|
||||
ws_pty_bridge: Arc::new(OutputBridge::new()),
|
||||
security_logger: Arc::new(StderrSecurityLogger),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(config: ServerConfig, pairing_code: impl Into<String>) -> Self {
|
||||
Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test_with_logger(
|
||||
config: ServerConfig,
|
||||
pairing_code: impl Into<String>,
|
||||
security_logger: Arc<dyn SecurityLogger>,
|
||||
) -> Self {
|
||||
Self {
|
||||
app: AppState::build(config.app_data_dir.clone()),
|
||||
config,
|
||||
pairing_code: pairing_code.into(),
|
||||
sessions: Mutex::new(HashSet::new()),
|
||||
ws_pty_bridge: Arc::new(OutputBridge::new()),
|
||||
security_logger,
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,6 +295,93 @@ impl ServerState {
|
||||
.map(|sessions| sessions.contains(token))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn revoke_session(&self, token: &str) -> bool {
|
||||
self.sessions
|
||||
.lock()
|
||||
.map(|mut sessions| sessions.remove(token))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn log_security(&self, event: SecurityLogEvent) {
|
||||
self.security_logger.log(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum SecurityLogEvent {
|
||||
PairingSucceeded {
|
||||
origin: Option<String>,
|
||||
},
|
||||
PairingFailed {
|
||||
origin: Option<String>,
|
||||
reason: &'static str,
|
||||
},
|
||||
OriginRejected {
|
||||
origin: Option<String>,
|
||||
route: String,
|
||||
},
|
||||
WsUpgradeRejected {
|
||||
origin: Option<String>,
|
||||
reason: &'static str,
|
||||
},
|
||||
SessionRevoked {
|
||||
origin: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
trait SecurityLogger: Send + Sync {
|
||||
fn log(&self, event: SecurityLogEvent);
|
||||
}
|
||||
|
||||
struct StderrSecurityLogger;
|
||||
|
||||
impl SecurityLogger for StderrSecurityLogger {
|
||||
fn log(&self, event: SecurityLogEvent) {
|
||||
eprintln!("idea --serve security: {}", security_log_line(&event));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct NoopSecurityLogger;
|
||||
|
||||
#[cfg(test)]
|
||||
impl SecurityLogger for NoopSecurityLogger {
|
||||
fn log(&self, _event: SecurityLogEvent) {}
|
||||
}
|
||||
|
||||
fn security_log_line(event: &SecurityLogEvent) -> String {
|
||||
match event {
|
||||
SecurityLogEvent::PairingSucceeded { origin } => {
|
||||
format!("pairing succeeded origin={}", origin_label(origin))
|
||||
}
|
||||
SecurityLogEvent::PairingFailed { origin, reason } => {
|
||||
format!(
|
||||
"pairing failed reason={reason} origin={}",
|
||||
origin_label(origin)
|
||||
)
|
||||
}
|
||||
SecurityLogEvent::OriginRejected { origin, route } => {
|
||||
format!(
|
||||
"origin rejected route={} origin={}",
|
||||
route,
|
||||
origin_label(origin)
|
||||
)
|
||||
}
|
||||
SecurityLogEvent::WsUpgradeRejected { origin, reason } => {
|
||||
format!(
|
||||
"websocket upgrade rejected reason={reason} origin={}",
|
||||
origin_label(origin)
|
||||
)
|
||||
}
|
||||
SecurityLogEvent::SessionRevoked { origin } => {
|
||||
format!("session revoked origin={}", origin_label(origin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_label(origin: &Option<String>) -> &str {
|
||||
origin.as_deref().unwrap_or("<missing>")
|
||||
}
|
||||
|
||||
async fn run_server(config: ServerConfig, state: Arc<ServerState>) -> Result<(), String> {
|
||||
@ -444,8 +592,21 @@ fn validate_ws_upgrade(
|
||||
headers: &HeaderMap,
|
||||
state: &ServerState,
|
||||
) -> Result<String, Box<Response<ResponseBody>>> {
|
||||
let origin = validate_request_origin(headers, &state.config)?;
|
||||
let origin = match validate_request_origin(headers, &state.config) {
|
||||
Ok(origin) => origin,
|
||||
Err(response) => {
|
||||
state.log_security(SecurityLogEvent::OriginRejected {
|
||||
origin: request_origin(headers),
|
||||
route: WS_PATH.to_owned(),
|
||||
});
|
||||
return Err(response);
|
||||
}
|
||||
};
|
||||
let Some(token) = session_cookie(headers) else {
|
||||
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||
origin: origin.clone(),
|
||||
reason: "missing_session",
|
||||
});
|
||||
return Err(Box::new(error_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"UNAUTHORIZED",
|
||||
@ -454,6 +615,10 @@ fn validate_ws_upgrade(
|
||||
)));
|
||||
};
|
||||
if !state.has_session(&token) {
|
||||
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||
origin: origin.clone(),
|
||||
reason: "invalid_session",
|
||||
});
|
||||
return Err(Box::new(error_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"UNAUTHORIZED",
|
||||
@ -464,6 +629,10 @@ fn validate_ws_upgrade(
|
||||
if !header_contains_token(headers, "upgrade", "websocket")
|
||||
|| !header_contains_token(headers, "connection", "upgrade")
|
||||
{
|
||||
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||
origin: origin.clone(),
|
||||
reason: "invalid_upgrade",
|
||||
});
|
||||
return Err(Box::new(error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID",
|
||||
@ -475,6 +644,10 @@ fn validate_ws_upgrade(
|
||||
.get("sec-websocket-key")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||
origin: origin.clone(),
|
||||
reason: "missing_key",
|
||||
});
|
||||
return Err(Box::new(error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID",
|
||||
@ -940,26 +1113,58 @@ async fn dispatch_http(
|
||||
body: Bytes,
|
||||
state: Arc<ServerState>,
|
||||
) -> Response<ResponseBody> {
|
||||
let origin = match validate_request_origin(&headers, &state.config) {
|
||||
Ok(origin) => origin,
|
||||
Err(response) => return *response,
|
||||
};
|
||||
|
||||
if has_forbidden_query_secret(&uri) {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID",
|
||||
"secrets in URL query strings are forbidden",
|
||||
origin.as_deref(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
if !is_api_path(uri.path()) {
|
||||
return serve_static(&state.config.web_root, method, uri.path()).await;
|
||||
}
|
||||
|
||||
let origin = match validate_request_origin(&headers, &state.config) {
|
||||
Ok(origin) => origin,
|
||||
Err(response) => {
|
||||
state.log_security(SecurityLogEvent::OriginRejected {
|
||||
origin: request_origin(&headers),
|
||||
route: uri.path().to_owned(),
|
||||
});
|
||||
return *response;
|
||||
}
|
||||
};
|
||||
|
||||
if method == Method::OPTIONS {
|
||||
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
|
||||
}
|
||||
|
||||
match (method, uri.path()) {
|
||||
(Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await,
|
||||
(Method::POST, "/api/logout") => {
|
||||
let Some(token) = session_cookie(&headers) else {
|
||||
return error_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"UNAUTHORIZED",
|
||||
"missing session cookie",
|
||||
origin.as_deref(),
|
||||
);
|
||||
};
|
||||
if !state.revoke_session(&token) {
|
||||
return error_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"UNAUTHORIZED",
|
||||
"invalid session cookie",
|
||||
origin.as_deref(),
|
||||
);
|
||||
}
|
||||
state.log_security(SecurityLogEvent::SessionRevoked {
|
||||
origin: origin.clone(),
|
||||
});
|
||||
logout_response(&state, origin.as_deref())
|
||||
}
|
||||
(Method::POST, "/api/invoke") => {
|
||||
let Some(token) = session_cookie(&headers) else {
|
||||
return error_response(
|
||||
@ -979,18 +1184,136 @@ async fn dispatch_http(
|
||||
}
|
||||
invoke(body, state, origin.as_deref()).await
|
||||
}
|
||||
(_, "/api/pair" | "/api/invoke") => error_response(
|
||||
(_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"METHOD_NOT_ALLOWED",
|
||||
"method not allowed",
|
||||
origin.as_deref(),
|
||||
),
|
||||
_ => error_response(
|
||||
_ if is_api_path(uri.path()) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"NOT_FOUND",
|
||||
"route not found",
|
||||
origin.as_deref(),
|
||||
),
|
||||
_ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_api_path(path: &str) -> bool {
|
||||
path == "/api" || path.starts_with("/api/")
|
||||
}
|
||||
|
||||
async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response<ResponseBody> {
|
||||
if !matches!(method, Method::GET | Method::HEAD) {
|
||||
return error_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"METHOD_NOT_ALLOWED",
|
||||
"method not allowed",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let Some(route) = static_route(web_root, path) else {
|
||||
return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None);
|
||||
};
|
||||
|
||||
let file = if route.candidate.is_file() {
|
||||
route.candidate
|
||||
} else if route.spa_fallback {
|
||||
web_root.join("index.html")
|
||||
} else {
|
||||
return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None);
|
||||
};
|
||||
|
||||
let bytes = match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => {
|
||||
return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None);
|
||||
}
|
||||
};
|
||||
static_file_response(&file, bytes, method == Method::HEAD)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StaticRoute {
|
||||
candidate: PathBuf,
|
||||
spa_fallback: bool,
|
||||
}
|
||||
|
||||
fn static_route(web_root: &Path, request_path: &str) -> Option<StaticRoute> {
|
||||
if !request_path.starts_with('/') || request_path.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
let lower = request_path.to_ascii_lowercase();
|
||||
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut candidate = web_root.to_path_buf();
|
||||
let mut last = "";
|
||||
for segment in request_path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
{
|
||||
if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') {
|
||||
return None;
|
||||
}
|
||||
candidate.push(segment);
|
||||
last = segment;
|
||||
}
|
||||
|
||||
if request_path == "/" {
|
||||
return Some(StaticRoute {
|
||||
candidate: web_root.join("index.html"),
|
||||
spa_fallback: false,
|
||||
});
|
||||
}
|
||||
|
||||
Some(StaticRoute {
|
||||
candidate,
|
||||
spa_fallback: !last.contains('.'),
|
||||
})
|
||||
}
|
||||
|
||||
fn static_file_response(path: &Path, bytes: Vec<u8>, head_only: bool) -> Response<ResponseBody> {
|
||||
let is_index = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == "index.html");
|
||||
let body = if head_only { Vec::new() } else { bytes };
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, content_type(path))
|
||||
.header("x-content-type-options", "nosniff")
|
||||
.body(Full::new(Bytes::from(body)))
|
||||
.expect("static response is valid");
|
||||
if is_index {
|
||||
response.headers_mut().insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'",
|
||||
),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn content_type(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") {
|
||||
"html" => "text/html; charset=utf-8",
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"ico" => "image/x-icon",
|
||||
"wasm" => "application/wasm",
|
||||
"txt" => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@ -1017,6 +1340,10 @@ async fn pair(
|
||||
};
|
||||
|
||||
if request.code != state.pairing_code() {
|
||||
state.log_security(SecurityLogEvent::PairingFailed {
|
||||
origin: origin.map(str::to_owned),
|
||||
reason: "wrong_code",
|
||||
});
|
||||
return error_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"FORBIDDEN",
|
||||
@ -1026,6 +1353,9 @@ async fn pair(
|
||||
}
|
||||
|
||||
let token = state.create_session();
|
||||
state.log_security(SecurityLogEvent::PairingSucceeded {
|
||||
origin: origin.map(str::to_owned),
|
||||
});
|
||||
let cookie = Cookie::build((SESSION_COOKIE, token))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@ -1040,6 +1370,21 @@ async fn pair(
|
||||
response
|
||||
}
|
||||
|
||||
fn logout_response(state: &ServerState, origin: Option<&str>) -> Response<ResponseBody> {
|
||||
let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin);
|
||||
let secure = if state.config.secure_cookie() {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0");
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InvokeRequest {
|
||||
command: String,
|
||||
@ -1313,6 +1658,13 @@ fn validate_request_origin(
|
||||
}
|
||||
}
|
||||
|
||||
fn request_origin(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get(ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn origin_allowed(origin: &str, config: &ServerConfig) -> bool {
|
||||
if let Some(public_origin) = &config.public_origin {
|
||||
return origin == public_origin;
|
||||
@ -1948,6 +2300,36 @@ mod tests {
|
||||
use http::header::HeaderName;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingSecurityLogger {
|
||||
events: Mutex<Vec<SecurityLogEvent>>,
|
||||
}
|
||||
|
||||
impl RecordingSecurityLogger {
|
||||
fn events(&self) -> Vec<SecurityLogEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityLogger for RecordingSecurityLogger {
|
||||
fn log(&self, event: SecurityLogEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_web_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(root.join("assets")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("index.html"),
|
||||
r#"<!doctype html><div id="root"></div><script type="module" src="/assets/app.js"></script>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap();
|
||||
std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn test_config() -> ServerConfig {
|
||||
ServerConfig {
|
||||
listen: "127.0.0.1:17373".parse().unwrap(),
|
||||
@ -1955,6 +2337,7 @@ mod tests {
|
||||
allow_remote: false,
|
||||
trust_reverse_proxy: false,
|
||||
app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())),
|
||||
web_root: create_web_root(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1998,6 +2381,13 @@ mod tests {
|
||||
(status, value, headers)
|
||||
}
|
||||
|
||||
async fn response_bytes(response: Response<ResponseBody>) -> (StatusCode, Bytes, HeaderMap) {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
(status, body, headers)
|
||||
}
|
||||
|
||||
async fn pair_and_cookie(state: Arc<ServerState>) -> String {
|
||||
let response = request(
|
||||
state,
|
||||
@ -3017,6 +3407,130 @@ mod tests {
|
||||
assert_eq!(body["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_session_for_http_and_websocket() {
|
||||
let state = state();
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
|
||||
let response = request(
|
||||
Arc::clone(&state),
|
||||
Method::POST,
|
||||
"/api/logout",
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await;
|
||||
let (status, body, headers) = response_json(response).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, json!({ "revoked": true }));
|
||||
assert!(headers
|
||||
.get(SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("Max-Age=0"));
|
||||
|
||||
let response = request(
|
||||
Arc::clone(&state),
|
||||
Method::POST,
|
||||
"/api/invoke",
|
||||
json!({ "command": "health", "args": {} }),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await;
|
||||
let (status, body, _) = response_json(response).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body["code"], "UNAUTHORIZED");
|
||||
|
||||
let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie));
|
||||
let response = validate_ws_upgrade(&headers, &state).unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() {
|
||||
let logger = Arc::new(RecordingSecurityLogger::default());
|
||||
let state = Arc::new(ServerState::new_for_test_with_logger(
|
||||
test_config(),
|
||||
"PAIR1234",
|
||||
logger.clone(),
|
||||
));
|
||||
|
||||
let bad_origin = handle_request(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/pair")
|
||||
.header(ORIGIN, "https://evil.example")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#)))
|
||||
.unwrap(),
|
||||
Arc::clone(&state),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let wrong_code = request(
|
||||
Arc::clone(&state),
|
||||
Method::POST,
|
||||
"/api/pair",
|
||||
json!({ "code": "WRONG999" }),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
let _ = request(
|
||||
Arc::clone(&state),
|
||||
Method::POST,
|
||||
"/api/logout",
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let headers = ws_headers(
|
||||
"http://127.0.0.1:17373",
|
||||
Some("idea_session=invalid-token-value"),
|
||||
);
|
||||
let _ = validate_ws_upgrade(&headers, &state);
|
||||
|
||||
let events = logger.events();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair"
|
||||
)));
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
SecurityLogEvent::PairingFailed {
|
||||
reason: "wrong_code",
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. })));
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
SecurityLogEvent::WsUpgradeRejected {
|
||||
reason: "invalid_session",
|
||||
..
|
||||
}
|
||||
)));
|
||||
|
||||
let lines = events.iter().map(security_log_line).collect::<Vec<_>>();
|
||||
assert!(
|
||||
!lines.iter().any(|line| line.contains("PAIR1234")
|
||||
|| line.contains("WRONG999")
|
||||
|| line.contains("invalid-token-value")),
|
||||
"security logs must not leak pairing codes or session tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pairing_rejects_wrong_code() {
|
||||
let state = state();
|
||||
@ -3114,6 +3628,112 @@ mod tests {
|
||||
assert_eq!(body["code"], "INVALID");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_web_index_same_origin_with_csp() {
|
||||
let state = state();
|
||||
|
||||
let response = request(state, Method::GET, "/", json!({}), &[]).await;
|
||||
let (status, body, headers) = response_bytes(response).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\""));
|
||||
assert_eq!(
|
||||
headers.get(CONTENT_TYPE).unwrap(),
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert!(headers.contains_key("content-security-policy"));
|
||||
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_static_assets_with_mime_types() {
|
||||
let state = state();
|
||||
|
||||
let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await;
|
||||
let (status, body, headers) = response_bytes(response).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');");
|
||||
assert_eq!(
|
||||
headers.get(CONTENT_TYPE).unwrap(),
|
||||
"text/javascript; charset=utf-8"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_spa_fallback_for_non_api_routes_without_extension() {
|
||||
let state = state();
|
||||
|
||||
let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await;
|
||||
let (status, body, headers) = response_bytes(response).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\""));
|
||||
assert!(headers.contains_key("content-security-policy"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_asset_and_api_routes_do_not_fallback_to_spa() {
|
||||
let state = state();
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
|
||||
let missing_asset = request(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
"/assets/missing.js",
|
||||
json!({}),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
let (asset_status, asset_body, _) = response_json(missing_asset).await;
|
||||
assert_eq!(asset_status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(asset_body["code"], "NOT_FOUND");
|
||||
|
||||
let api_unknown = request(
|
||||
state,
|
||||
Method::GET,
|
||||
"/api/unknown",
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await;
|
||||
let (api_status, api_body, _) = response_json(api_unknown).await;
|
||||
assert_eq!(api_status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(api_body["code"], "NOT_FOUND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_route_rejects_traversal_and_dotfiles() {
|
||||
let root = PathBuf::from("/tmp/web");
|
||||
|
||||
assert!(static_route(&root, "/../secret").is_none());
|
||||
assert!(static_route(&root, "/%2e%2e/secret").is_none());
|
||||
assert!(static_route(&root, "/.env").is_none());
|
||||
assert_eq!(
|
||||
static_route(&root, "/dashboard").unwrap(),
|
||||
StaticRoute {
|
||||
candidate: root.join("dashboard"),
|
||||
spa_fallback: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
static_route(&root, "/assets/app.js").unwrap(),
|
||||
StaticRoute {
|
||||
candidate: root.join("assets").join("app.js"),
|
||||
spa_fallback: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_web_root_requires_index_html() {
|
||||
let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&missing).unwrap();
|
||||
|
||||
assert!(validate_web_root(missing, "--web-root").is_err());
|
||||
assert!(validate_web_root(create_web_root(), "--web-root").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_allowed_origin_is_refused() {
|
||||
let state = state();
|
||||
|
||||
Reference in New Issue
Block a user