diff --git a/web-dashboard/src/main.tsx b/web-dashboard/src/main.tsx index bef5202a3..6036865f3 100644 --- a/web-dashboard/src/main.tsx +++ b/web-dashboard/src/main.tsx @@ -3,6 +3,11 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +// Catch unhandled async errors globally (e.g. forgotten .catch() in event handlers) +window.addEventListener('unhandledrejection', (event) => { + console.error('[Unhandled Promise Rejection]', event.reason); +}); + createRoot(document.getElementById('root')!).render( diff --git a/web-dashboard/vite.config.ts b/web-dashboard/vite.config.ts index 4c61c0f1a..d0b6037aa 100644 --- a/web-dashboard/vite.config.ts +++ b/web-dashboard/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ }, }, build: { - sourcemap: true, + sourcemap: process.env.NODE_ENV !== 'production', rollupOptions: { output: { manualChunks: { diff --git a/web-gateway/src/main.rs b/web-gateway/src/main.rs index c3d15f1a6..3d7b1cb3a 100644 --- a/web-gateway/src/main.rs +++ b/web-gateway/src/main.rs @@ -103,12 +103,26 @@ async fn request_id_middleware(mut request: Request, next: Next) -> Response { async move { let mut response = next.run(request).await; + let headers = response.headers_mut(); // Echo the request id back on the response if let Ok(val) = request_id.parse() { - response.headers_mut().insert("x-request-id", val); + headers.insert("x-request-id", val); } + // Security headers (OWASP recommendations) + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); + headers.insert("x-content-type-options", HeaderValue::from_static("nosniff")); + headers.insert("x-xss-protection", HeaderValue::from_static("0")); + headers.insert( + "referrer-policy", + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + headers.insert( + "strict-transport-security", + HeaderValue::from_static("max-age=31536000; includeSubDomains"), + ); + response } .instrument(span) diff --git a/web-gateway/src/routes/trading.rs b/web-gateway/src/routes/trading.rs index 0e591320e..c07494a4e 100644 --- a/web-gateway/src/routes/trading.rs +++ b/web-gateway/src/routes/trading.rs @@ -71,11 +71,81 @@ struct SubmitOrderBody { client_order_id: Option, } +/// Validate order fields before forwarding to the gRPC trading service. +fn validate_order(body: &SubmitOrderBody) -> Result<(), AppError> { + // Symbol: non-empty, alphanumeric with dots, max 20 chars + if body.symbol.is_empty() || body.symbol.len() > 20 { + return Err(AppError::BadRequest( + "Symbol must be 1-20 characters".to_owned(), + )); + } + if !body + .symbol + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.') + { + return Err(AppError::BadRequest( + "Symbol must contain only alphanumeric characters and dots".to_owned(), + )); + } + + // Side: proto enum OrderSide (1 = Buy, 2 = Sell) + if body.side != 1 && body.side != 2 { + return Err(AppError::BadRequest( + "Side must be 1 (Buy) or 2 (Sell)".to_owned(), + )); + } + + // OrderType: proto enum (1 = Market, 2 = Limit, 3 = Stop, 4 = StopLimit) + if !(1..=4).contains(&body.order_type) { + return Err(AppError::BadRequest( + "Order type must be 1-4 (Market, Limit, Stop, StopLimit)".to_owned(), + )); + } + + // Quantity: positive, reasonable upper bound + if !body.quantity.is_finite() || body.quantity <= 0.0 || body.quantity > 1_000_000.0 { + return Err(AppError::BadRequest( + "Quantity must be between 0 and 1,000,000".to_owned(), + )); + } + + // Price: if provided, must be positive and finite + if let Some(price) = body.price { + if !price.is_finite() || price <= 0.0 { + return Err(AppError::BadRequest( + "Price must be a positive number".to_owned(), + )); + } + } + + // Limit orders require a price + if body.order_type == 2 && body.price.is_none() { + return Err(AppError::BadRequest( + "Limit orders require a price".to_owned(), + )); + } + + // Stop price: if provided, must be positive and finite + if let Some(stop) = body.stop_price { + if !stop.is_finite() || stop <= 0.0 { + return Err(AppError::BadRequest( + "Stop price must be a positive number".to_owned(), + )); + } + } + + Ok(()) +} + async fn submit_order( State(state): State, Extension(_claims): Extension, Json(body): Json, ) -> Result, AppError> { + // Server-side input validation (defense in depth — don't trust frontend) + validate_order(&body)?; + let channel = state .trading_channel .as_ref() @@ -321,4 +391,133 @@ mod tests { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + + // --- Order validation tests --- + + fn valid_order() -> SubmitOrderBody { + SubmitOrderBody { + symbol: "ES.FUT".to_string(), + side: 1, + order_type: 1, + quantity: 1.0, + price: None, + stop_price: None, + time_in_force: None, + client_order_id: None, + } + } + + #[test] + fn test_validate_order_valid() { + assert!(validate_order(&valid_order()).is_ok()); + } + + #[test] + fn test_validate_order_empty_symbol() { + let mut order = valid_order(); + order.symbol = String::new(); + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_bad_symbol_chars() { + let mut order = valid_order(); + order.symbol = "ES;DROP TABLE".to_string(); + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_invalid_side() { + let mut order = valid_order(); + order.side = 99; + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_invalid_order_type() { + let mut order = valid_order(); + order.order_type = 0; + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_negative_quantity() { + let mut order = valid_order(); + order.quantity = -1.0; + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_nan_quantity() { + let mut order = valid_order(); + order.quantity = f64::NAN; + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_limit_requires_price() { + let mut order = valid_order(); + order.order_type = 2; // Limit + order.price = None; + assert!(validate_order(&order).is_err()); + } + + #[test] + fn test_validate_order_limit_with_price_ok() { + let mut order = valid_order(); + order.order_type = 2; // Limit + order.price = Some(4500.0); + assert!(validate_order(&order).is_ok()); + } + + #[test] + fn test_validate_order_negative_price() { + let mut order = valid_order(); + order.price = Some(-100.0); + assert!(validate_order(&order).is_err()); + } + + #[tokio::test] + async fn test_submit_order_validation_rejects_bad_symbol() { + let state = test_state(false); + let app = test_app(state); + let token = make_token(); + let body = serde_json::json!({ + "symbol": "ES;DROP", + "side": 1, + "order_type": 1, + "quantity": 1.0, + }); + let req = Request::builder() + .method("POST") + .uri("/trading/orders") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_submit_order_validation_rejects_bad_side() { + let state = test_state(false); + let app = test_app(state); + let token = make_token(); + let body = serde_json::json!({ + "symbol": "ES.FUT", + "side": 99, + "order_type": 1, + "quantity": 1.0, + }); + let req = Request::builder() + .method("POST") + .uri("/trading/orders") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } }