diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 97802a05..7c31e303 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -1241,7 +1241,7 @@ async fn validate_ws_upgrade( ) -> Result<(String, AuthenticatedDevice), Box>> { validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?; - let origin = match validate_request_origin(headers, &state.config) { + let origin = match validate_request_origin(headers, &state.config, false) { Ok(origin) => origin, Err(response) => { state.log_security(SecurityLogEvent::OriginRejected { @@ -1800,7 +1800,11 @@ async fn dispatch_http( return *response; } - let origin = match validate_request_origin(&headers, &state.config) { + let origin = match validate_request_origin( + &headers, + &state.config, + api_route_allows_missing_origin(&method, uri.path()), + ) { Ok(origin) => origin, Err(response) => { state.log_security(SecurityLogEvent::OriginRejected { @@ -4954,8 +4958,12 @@ where fn validate_request_origin( headers: &HeaderMap, config: &ServerConfig, + allow_missing: bool, ) -> Result, Box>> { let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { + if allow_missing { + return Ok(None); + } return Err(Box::new(error_response( StatusCode::FORBIDDEN, "FORBIDDEN", @@ -4976,6 +4984,16 @@ fn validate_request_origin( } } +fn api_route_allows_missing_origin(method: &Method, path: &str) -> bool { + method == Method::GET + && matches!( + path, + "/api/project-files" + | "/api/project-files/download-file" + | "/api/project-files/download-directory" + ) +} + fn validate_reverse_proxy( headers: &HeaderMap, peer_ip: Option, @@ -6052,6 +6070,29 @@ mod tests { .await } + async fn request_without_origin( + state: Arc, + method: Method, + uri: &str, + body: Value, + extra_headers: &[(&str, &str)], + ) -> Response { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header(CONTENT_TYPE, "application/json"); + for (name, value) in extra_headers { + builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value); + } + handle_request( + builder + .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) + .unwrap(), + state, + ) + .await + } + async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { let status = response.status(); let headers = response.headers().clone(); @@ -6570,6 +6611,20 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + #[tokio::test] + async fn websocket_upgrade_requires_origin_header() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let mut headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); + headers.remove(ORIGIN); + + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + #[tokio::test] async fn websocket_upgrade_accepts_valid_cookie_and_origin() { let state = state(); @@ -7309,6 +7364,26 @@ mod tests { assert!(without_proxy.validate().is_err()); } + #[tokio::test] + async fn post_pair_requires_origin_header() { + let state = state(); + + let (status, body, _) = response_json( + request_without_origin( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Test device" }), + &[], + ) + .await, + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["message"], "missing Origin header"); + } + #[tokio::test] async fn remote_bind_rejects_peer_when_no_authorized_proxy_is_configured() { let logger = Arc::new(RecordingSecurityLogger::default()); @@ -8541,6 +8616,90 @@ mod tests { assert!(!entries.iter().any(|entry| entry["name"] == ".ideai")); } + #[tokio::test] + async fn authorized_project_file_gets_allow_missing_origin_header() { + let state = state(); + let project_id = create_project_for_test(&state, "Project Files No Origin").await; + let project = resolve_project_readonly(&project_id, &state.app) + .await + .expect("project resolves"); + std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap(); + std::fs::write( + format!("{}/src/main.rs", project.root.as_str()), + "fn main() {}\n", + ) + .unwrap(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let (list_status, list_body, _) = response_json( + request_without_origin( + Arc::clone(&state), + Method::GET, + &format!("/api/project-files?projectId={project_id}&path="), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + let (file_status, file_body, _) = response_bytes( + request_without_origin( + Arc::clone(&state), + Method::GET, + &format!( + "/api/project-files/download-file?projectId={project_id}&path=src/main.rs" + ), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + let (directory_status, directory_body, _) = response_bytes( + request_without_origin( + Arc::clone(&state), + Method::GET, + &format!("/api/project-files/download-directory?projectId={project_id}&path=src"), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + + assert_eq!(list_status, StatusCode::OK); + assert!(list_body["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "src")); + assert_eq!(file_status, StatusCode::OK); + assert_eq!(file_body, Bytes::from_static(b"fn main() {}\n")); + assert_eq!(directory_status, StatusCode::OK); + assert!(directory_body.starts_with(b"PK")); + } + + #[tokio::test] + async fn non_get_project_file_route_still_requires_origin_header() { + let state = state(); + let project_id = create_project_for_test(&state, "Project Files POST No Origin").await; + + let (status, body, _) = response_json( + request_without_origin( + state, + Method::POST, + &format!("/api/project-files?projectId={project_id}&path="), + json!({}), + &[], + ) + .await, + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["message"], "missing Origin header"); + } + #[tokio::test] async fn authorized_project_files_rejects_traversal_and_hidden_paths() { let state = state();