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:
2026-07-16 08:10:31 +02:00
parent dd1d083a1a
commit d538808a0d
2 changed files with 676 additions and 11 deletions

View File

@ -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();

View 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.