Skip to content

Commit 9070bde

Browse files
committed
Restrict local API CORS
1 parent 4a8694f commit 9070bde

1 file changed

Lines changed: 67 additions & 6 deletions

File tree

src/server.rs

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
//! POST /api/optimize → accepts OptimizerRequest JSON, returns top-3 OptimizationResult[]
77
88
use actix_cors::Cors;
9-
use actix_web::{App, HttpResponse, HttpServer, Responder, web};
9+
use actix_web::{
10+
App, HttpResponse, HttpServer, Responder,
11+
http::{Method, header},
12+
web,
13+
};
1014
use serde::{Deserialize, Serialize};
1115
use std::collections::{HashMap, HashSet};
1216
use std::sync::Arc;
@@ -160,6 +164,14 @@ fn validate_optimize_request(req: &OptimizeRequest, nodes: &[ResourceNode]) -> R
160164
Ok(())
161165
}
162166

167+
fn local_dashboard_cors() -> Cors {
168+
Cors::default()
169+
.allowed_origin("http://127.0.0.1:3000")
170+
.allowed_origin("http://localhost:3000")
171+
.allowed_methods([Method::GET, Method::POST])
172+
.allowed_header(header::CONTENT_TYPE)
173+
}
174+
163175
// ---------------------------------------------------------------------------
164176
// Phase preset builder (mirrors JS PRESETS)
165177
// ---------------------------------------------------------------------------
@@ -288,11 +300,8 @@ pub async fn run_server(port: u16) -> std::io::Result<()> {
288300
let data = Arc::new(AppState { nodes });
289301

290302
HttpServer::new(move || {
291-
// Allow all origins in dev (Vite runs on 3000, server on 8080)
292-
let cors = Cors::permissive();
293-
294303
App::new()
295-
.wrap(cors)
304+
.wrap(local_dashboard_cors())
296305
.app_data(web::Data::new(Arc::clone(&data)))
297306
.app_data(
298307
web::JsonConfig::default()
@@ -317,7 +326,11 @@ pub async fn run_server(port: u16) -> std::io::Result<()> {
317326
mod tests {
318327
use super::*;
319328
use crate::models::Purity;
320-
use actix_web::{App, http::StatusCode, test as actix_test};
329+
use actix_web::{
330+
App,
331+
http::{StatusCode, header},
332+
test as actix_test,
333+
};
321334

322335
#[test]
323336
fn presets_include_collectibles_and_late_water_wells() {
@@ -540,4 +553,52 @@ mod tests {
540553

541554
assert_eq!(response.status(), StatusCode::OK);
542555
}
556+
557+
#[actix_web::test]
558+
async fn cors_allows_local_dashboard_origin() {
559+
let app = actix_test::init_service(
560+
App::new()
561+
.wrap(local_dashboard_cors())
562+
.route("/api/health", web::get().to(get_health)),
563+
)
564+
.await;
565+
566+
let request = actix_test::TestRequest::default()
567+
.method(Method::OPTIONS)
568+
.uri("/api/health")
569+
.insert_header((header::ORIGIN, "http://127.0.0.1:3000"))
570+
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
571+
.to_request();
572+
let response = actix_test::call_service(&app, request).await;
573+
574+
assert_eq!(response.status(), StatusCode::OK);
575+
assert_eq!(
576+
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
577+
Some(&header::HeaderValue::from_static("http://127.0.0.1:3000"))
578+
);
579+
}
580+
581+
#[actix_web::test]
582+
async fn cors_rejects_arbitrary_browser_origin() {
583+
let app = actix_test::init_service(
584+
App::new()
585+
.wrap(local_dashboard_cors())
586+
.route("/api/health", web::get().to(get_health)),
587+
)
588+
.await;
589+
590+
let request = actix_test::TestRequest::default()
591+
.method(Method::OPTIONS)
592+
.uri("/api/health")
593+
.insert_header((header::ORIGIN, "https://example.com"))
594+
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
595+
.to_request();
596+
let response = actix_test::call_service(&app, request).await;
597+
598+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
599+
assert_eq!(
600+
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
601+
None
602+
);
603+
}
543604
}

0 commit comments

Comments
 (0)