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::collections::HashSet;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@ -95,6 +95,7 @@ struct ServerConfig {
|
|||||||
allow_remote: bool,
|
allow_remote: bool,
|
||||||
trust_reverse_proxy: bool,
|
trust_reverse_proxy: bool,
|
||||||
app_data_dir: PathBuf,
|
app_data_dir: PathBuf,
|
||||||
|
web_root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
@ -106,6 +107,7 @@ impl ServerConfig {
|
|||||||
let mut allow_remote = false;
|
let mut allow_remote = false;
|
||||||
let mut trust_reverse_proxy = false;
|
let mut trust_reverse_proxy = false;
|
||||||
let mut app_data_dir = default_app_data_dir();
|
let mut app_data_dir = default_app_data_dir();
|
||||||
|
let mut web_root = None;
|
||||||
|
|
||||||
let mut it = args.into_iter();
|
let mut it = args.into_iter();
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
@ -132,10 +134,17 @@ impl ServerConfig {
|
|||||||
.ok_or_else(|| "--app-data-dir requires a path".to_owned())?;
|
.ok_or_else(|| "--app-data-dir requires a path".to_owned())?;
|
||||||
app_data_dir = PathBuf::from(value);
|
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()),
|
"--help" | "-h" => return Err(Self::usage()),
|
||||||
other => return Err(format!("unknown --serve argument: {other}")),
|
other => return Err(format!("unknown --serve argument: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let web_root = resolve_web_root(web_root)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
listen,
|
listen,
|
||||||
@ -143,6 +152,7 @@ impl ServerConfig {
|
|||||||
allow_remote,
|
allow_remote,
|
||||||
trust_reverse_proxy,
|
trust_reverse_proxy,
|
||||||
app_data_dir,
|
app_data_dir,
|
||||||
|
web_root,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,7 +192,46 @@ impl ServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
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,
|
pairing_code: String,
|
||||||
sessions: Mutex<HashSet<String>>,
|
sessions: Mutex<HashSet<String>>,
|
||||||
ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>,
|
ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>,
|
||||||
|
security_logger: Arc<dyn SecurityLogger>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerState {
|
impl ServerState {
|
||||||
@ -202,17 +252,28 @@ impl ServerState {
|
|||||||
pairing_code: new_pairing_code(),
|
pairing_code: new_pairing_code(),
|
||||||
sessions: Mutex::new(HashSet::new()),
|
sessions: Mutex::new(HashSet::new()),
|
||||||
ws_pty_bridge: Arc::new(OutputBridge::new()),
|
ws_pty_bridge: Arc::new(OutputBridge::new()),
|
||||||
|
security_logger: Arc::new(StderrSecurityLogger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn new_for_test(config: ServerConfig, pairing_code: impl Into<String>) -> Self {
|
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 {
|
Self {
|
||||||
app: AppState::build(config.app_data_dir.clone()),
|
app: AppState::build(config.app_data_dir.clone()),
|
||||||
config,
|
config,
|
||||||
pairing_code: pairing_code.into(),
|
pairing_code: pairing_code.into(),
|
||||||
sessions: Mutex::new(HashSet::new()),
|
sessions: Mutex::new(HashSet::new()),
|
||||||
ws_pty_bridge: Arc::new(OutputBridge::new()),
|
ws_pty_bridge: Arc::new(OutputBridge::new()),
|
||||||
|
security_logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,6 +295,93 @@ impl ServerState {
|
|||||||
.map(|sessions| sessions.contains(token))
|
.map(|sessions| sessions.contains(token))
|
||||||
.unwrap_or(false)
|
.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> {
|
async fn run_server(config: ServerConfig, state: Arc<ServerState>) -> Result<(), String> {
|
||||||
@ -444,8 +592,21 @@ fn validate_ws_upgrade(
|
|||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
) -> Result<String, Box<Response<ResponseBody>>> {
|
) -> 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 {
|
let Some(token) = session_cookie(headers) else {
|
||||||
|
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||||
|
origin: origin.clone(),
|
||||||
|
reason: "missing_session",
|
||||||
|
});
|
||||||
return Err(Box::new(error_response(
|
return Err(Box::new(error_response(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"UNAUTHORIZED",
|
"UNAUTHORIZED",
|
||||||
@ -454,6 +615,10 @@ fn validate_ws_upgrade(
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
if !state.has_session(&token) {
|
if !state.has_session(&token) {
|
||||||
|
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||||
|
origin: origin.clone(),
|
||||||
|
reason: "invalid_session",
|
||||||
|
});
|
||||||
return Err(Box::new(error_response(
|
return Err(Box::new(error_response(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"UNAUTHORIZED",
|
"UNAUTHORIZED",
|
||||||
@ -464,6 +629,10 @@ fn validate_ws_upgrade(
|
|||||||
if !header_contains_token(headers, "upgrade", "websocket")
|
if !header_contains_token(headers, "upgrade", "websocket")
|
||||||
|| !header_contains_token(headers, "connection", "upgrade")
|
|| !header_contains_token(headers, "connection", "upgrade")
|
||||||
{
|
{
|
||||||
|
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||||
|
origin: origin.clone(),
|
||||||
|
reason: "invalid_upgrade",
|
||||||
|
});
|
||||||
return Err(Box::new(error_response(
|
return Err(Box::new(error_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"INVALID",
|
"INVALID",
|
||||||
@ -475,6 +644,10 @@ fn validate_ws_upgrade(
|
|||||||
.get("sec-websocket-key")
|
.get("sec-websocket-key")
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
else {
|
else {
|
||||||
|
state.log_security(SecurityLogEvent::WsUpgradeRejected {
|
||||||
|
origin: origin.clone(),
|
||||||
|
reason: "missing_key",
|
||||||
|
});
|
||||||
return Err(Box::new(error_response(
|
return Err(Box::new(error_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"INVALID",
|
"INVALID",
|
||||||
@ -940,26 +1113,58 @@ async fn dispatch_http(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
) -> Response<ResponseBody> {
|
) -> Response<ResponseBody> {
|
||||||
let origin = match validate_request_origin(&headers, &state.config) {
|
|
||||||
Ok(origin) => origin,
|
|
||||||
Err(response) => return *response,
|
|
||||||
};
|
|
||||||
|
|
||||||
if has_forbidden_query_secret(&uri) {
|
if has_forbidden_query_secret(&uri) {
|
||||||
return error_response(
|
return error_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"INVALID",
|
"INVALID",
|
||||||
"secrets in URL query strings are forbidden",
|
"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 {
|
if method == Method::OPTIONS {
|
||||||
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
|
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
match (method, uri.path()) {
|
match (method, uri.path()) {
|
||||||
(Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await,
|
(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") => {
|
(Method::POST, "/api/invoke") => {
|
||||||
let Some(token) = session_cookie(&headers) else {
|
let Some(token) = session_cookie(&headers) else {
|
||||||
return error_response(
|
return error_response(
|
||||||
@ -979,18 +1184,136 @@ async fn dispatch_http(
|
|||||||
}
|
}
|
||||||
invoke(body, state, origin.as_deref()).await
|
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,
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
"METHOD_NOT_ALLOWED",
|
"METHOD_NOT_ALLOWED",
|
||||||
"method not allowed",
|
"method not allowed",
|
||||||
origin.as_deref(),
|
origin.as_deref(),
|
||||||
),
|
),
|
||||||
_ => error_response(
|
_ if is_api_path(uri.path()) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
"route not found",
|
"route not found",
|
||||||
origin.as_deref(),
|
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() {
|
if request.code != state.pairing_code() {
|
||||||
|
state.log_security(SecurityLogEvent::PairingFailed {
|
||||||
|
origin: origin.map(str::to_owned),
|
||||||
|
reason: "wrong_code",
|
||||||
|
});
|
||||||
return error_response(
|
return error_response(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
@ -1026,6 +1353,9 @@ async fn pair(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let token = state.create_session();
|
let token = state.create_session();
|
||||||
|
state.log_security(SecurityLogEvent::PairingSucceeded {
|
||||||
|
origin: origin.map(str::to_owned),
|
||||||
|
});
|
||||||
let cookie = Cookie::build((SESSION_COOKIE, token))
|
let cookie = Cookie::build((SESSION_COOKIE, token))
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
@ -1040,6 +1370,21 @@ async fn pair(
|
|||||||
response
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct InvokeRequest {
|
struct InvokeRequest {
|
||||||
command: String,
|
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 {
|
fn origin_allowed(origin: &str, config: &ServerConfig) -> bool {
|
||||||
if let Some(public_origin) = &config.public_origin {
|
if let Some(public_origin) = &config.public_origin {
|
||||||
return origin == public_origin;
|
return origin == public_origin;
|
||||||
@ -1948,6 +2300,36 @@ mod tests {
|
|||||||
use http::header::HeaderName;
|
use http::header::HeaderName;
|
||||||
use std::time::Duration;
|
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 {
|
fn test_config() -> ServerConfig {
|
||||||
ServerConfig {
|
ServerConfig {
|
||||||
listen: "127.0.0.1:17373".parse().unwrap(),
|
listen: "127.0.0.1:17373".parse().unwrap(),
|
||||||
@ -1955,6 +2337,7 @@ mod tests {
|
|||||||
allow_remote: false,
|
allow_remote: false,
|
||||||
trust_reverse_proxy: false,
|
trust_reverse_proxy: false,
|
||||||
app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())),
|
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)
|
(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 {
|
async fn pair_and_cookie(state: Arc<ServerState>) -> String {
|
||||||
let response = request(
|
let response = request(
|
||||||
state,
|
state,
|
||||||
@ -3017,6 +3407,130 @@ mod tests {
|
|||||||
assert_eq!(body["code"], "UNAUTHORIZED");
|
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]
|
#[tokio::test]
|
||||||
async fn pairing_rejects_wrong_code() {
|
async fn pairing_rejects_wrong_code() {
|
||||||
let state = state();
|
let state = state();
|
||||||
@ -3114,6 +3628,112 @@ mod tests {
|
|||||||
assert_eq!(body["code"], "INVALID");
|
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]
|
#[tokio::test]
|
||||||
async fn non_allowed_origin_is_refused() {
|
async fn non_allowed_origin_is_refused() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
45
docs/server-client-mode-remote.md
Normal file
45
docs/server-client-mode-remote.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# IdeA Server/Client Remote Deployment
|
||||||
|
|
||||||
|
`idea --serve` serves the web UI and the API from the same origin. The frontend
|
||||||
|
build must be available as a web root containing `index.html`.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --dir frontend build
|
||||||
|
idea --serve --web-root frontend/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
The default local listener is `127.0.0.1:17373`. Loopback development may use
|
||||||
|
plain HTTP; the session cookie is still `HttpOnly` and `SameSite=Strict`.
|
||||||
|
|
||||||
|
## Packaged Artifact
|
||||||
|
|
||||||
|
The release artifact is still a single IdeA binary/AppImage. Packaging must copy
|
||||||
|
the frontend build output (`frontend/dist`) into the packaged `web/` resource
|
||||||
|
next to the binary. `idea --serve` resolves assets in this order:
|
||||||
|
|
||||||
|
1. `--web-root PATH`
|
||||||
|
2. `IDEA_WEB_ROOT`
|
||||||
|
3. packaged `web/`, then packaged `frontend/dist/`
|
||||||
|
4. development fallback `frontend/dist` relative to the current directory
|
||||||
|
|
||||||
|
If no `index.html` is found, the server refuses to start.
|
||||||
|
|
||||||
|
## Remote HTTPS
|
||||||
|
|
||||||
|
Public exposure requires a reverse proxy that terminates HTTPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idea --serve \
|
||||||
|
--listen 127.0.0.1:17373 \
|
||||||
|
--allow-remote \
|
||||||
|
--public-origin https://idea.example.com \
|
||||||
|
--trust-reverse-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`.
|
||||||
|
Do not expose the backend on a public non-loopback address without TLS proxying.
|
||||||
|
Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an
|
||||||
|
`HttpOnly`, `Secure`, `SameSite=Strict` session cookie.
|
||||||
|
|
||||||
Reference in New Issue
Block a user