From 544315e351e3df4d79eaf4c84e092479f4c20992 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 22 Feb 2026 08:34:37 +0100 Subject: [PATCH] safety(web-gateway,web-dashboard): harden for production deployment Backend: - Fail fast on empty JWT_SECRET at startup - Restrict CORS to explicit methods (GET/POST/PUT/DELETE/OPTIONS) and headers (Authorization, Content-Type) instead of Any - Add 1MB request body size limit (DefaultBodyLimit) - Add gRPC status mappings: DeadlineExceeded->408, Cancelled->499, AlreadyExists->409, ResourceExhausted->429, Unavailable->503 Frontend: - Guard ErrorBoundary console.error behind import.meta.env.DEV - Read VITE_API_URL and VITE_WS_URL from env vars with fallbacks - Add max reconnect attempts (50) to WebSocket manager - Tune React Query: staleTime 10s, refetchInterval 15s, disable refetchOnWindowFocus to prevent thundering herd Co-Authored-By: Claude Opus 4.6 --- web-dashboard/src/App.tsx | 5 ++- .../src/components/common/ErrorBoundary.tsx | 5 ++- web-dashboard/src/lib/api.ts | 2 +- web-dashboard/src/lib/websocket.ts | 19 ++++++++- web-gateway/src/error.rs | 41 +++++++++++++++++++ web-gateway/src/main.rs | 23 ++++++++--- 6 files changed, 83 insertions(+), 12 deletions(-) diff --git a/web-dashboard/src/App.tsx b/web-dashboard/src/App.tsx index f9db3f815..1bee8fdc0 100644 --- a/web-dashboard/src/App.tsx +++ b/web-dashboard/src/App.tsx @@ -14,8 +14,9 @@ import { ConfigDashboard } from './pages/ConfigDashboard'; const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5000, - refetchInterval: 10000, + staleTime: 10_000, + refetchInterval: 15_000, + refetchOnWindowFocus: false, }, }, }); diff --git a/web-dashboard/src/components/common/ErrorBoundary.tsx b/web-dashboard/src/components/common/ErrorBoundary.tsx index 000eb478a..344684660 100644 --- a/web-dashboard/src/components/common/ErrorBoundary.tsx +++ b/web-dashboard/src/components/common/ErrorBoundary.tsx @@ -20,7 +20,10 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: ErrorInfo) { - console.error('ErrorBoundary caught:', error, info.componentStack); + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.error('ErrorBoundary caught:', error, info.componentStack); + } } render() { diff --git a/web-dashboard/src/lib/api.ts b/web-dashboard/src/lib/api.ts index c5a1c48e1..4aca026cb 100644 --- a/web-dashboard/src/lib/api.ts +++ b/web-dashboard/src/lib/api.ts @@ -1,6 +1,6 @@ import { clearTokens } from './auth'; -const API_BASE = '/api'; +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; /** Fetch wrapper that injects JWT Authorization header and handles 401 auto-logout */ async function apiFetch( diff --git a/web-dashboard/src/lib/websocket.ts b/web-dashboard/src/lib/websocket.ts index df5abdd88..4f7e10f3b 100644 --- a/web-dashboard/src/lib/websocket.ts +++ b/web-dashboard/src/lib/websocket.ts @@ -10,12 +10,21 @@ export class WsManager { private reconnectTimer: ReturnType | null = null; private backoff = 1000; private maxBackoff = 30000; + private maxAttempts = 50; + private attempts = 0; private subscribedTopics: Set = new Set(); private _connected = false; constructor(url?: string) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - this.url = url ?? `${protocol}//${window.location.host}/api/ws`; + const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; + if (url) { + this.url = url; + } else if (envWsUrl) { + this.url = envWsUrl; + } else { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + this.url = `${protocol}//${window.location.host}/api/ws`; + } } get connected(): boolean { @@ -33,6 +42,7 @@ export class WsManager { this.ws.onopen = () => { this._connected = true; this.backoff = 1000; + this.attempts = 0; // Re-subscribe to previously subscribed topics if (this.subscribedTopics.size > 0) { this.send({ type: 'subscribe', topics: Array.from(this.subscribedTopics) }); @@ -99,6 +109,11 @@ export class WsManager { private scheduleReconnect(): void { if (this.reconnectTimer) return; + this.attempts += 1; + if (this.attempts > this.maxAttempts) { + // Stop reconnecting after too many failures + return; + } const delay = this.backoff; this.backoff = Math.min(this.backoff * 2, this.maxBackoff); this.reconnectTimer = setTimeout(() => { diff --git a/web-gateway/src/error.rs b/web-gateway/src/error.rs index c76f89704..55b1bf63e 100644 --- a/web-gateway/src/error.rs +++ b/web-gateway/src/error.rs @@ -32,6 +32,12 @@ impl IntoResponse for AppError { tonic::Code::PermissionDenied => StatusCode::FORBIDDEN, tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST, tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED, + tonic::Code::DeadlineExceeded => StatusCode::REQUEST_TIMEOUT, + tonic::Code::Cancelled => StatusCode::from_u16(499) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + tonic::Code::AlreadyExists => StatusCode::CONFLICT, + tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS, + tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::INTERNAL_SERVER_ERROR, }, s.message().to_owned(), @@ -118,4 +124,39 @@ mod tests { let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + + #[test] + fn test_grpc_deadline_exceeded_maps_to_408() { + let status = tonic::Status::deadline_exceeded("timed out"); + let resp = AppError::GrpcError(status).into_response(); + assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT); + } + + #[test] + fn test_grpc_cancelled_maps_to_499() { + let status = tonic::Status::cancelled("client cancelled"); + let resp = AppError::GrpcError(status).into_response(); + assert_eq!(resp.status().as_u16(), 499); + } + + #[test] + fn test_grpc_already_exists_maps_to_409() { + let status = tonic::Status::already_exists("duplicate"); + let resp = AppError::GrpcError(status).into_response(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + } + + #[test] + fn test_grpc_resource_exhausted_maps_to_429() { + let status = tonic::Status::resource_exhausted("rate limited"); + let resp = AppError::GrpcError(status).into_response(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[test] + fn test_grpc_unavailable_maps_to_503() { + let status = tonic::Status::unavailable("service down"); + let resp = AppError::GrpcError(status).into_response(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } } diff --git a/web-gateway/src/main.rs b/web-gateway/src/main.rs index ae77b8714..a99edca59 100644 --- a/web-gateway/src/main.rs +++ b/web-gateway/src/main.rs @@ -1,6 +1,7 @@ -use anyhow::Result; -use axum::http::HeaderValue; -use tower_http::cors::CorsLayer; +use anyhow::{bail, Result}; +use axum::extract::DefaultBodyLimit; +use axum::http::{HeaderValue, Method}; +use tower_http::cors::{AllowHeaders, CorsLayer}; use tower_http::trace::TraceLayer; use tracing::info; @@ -14,6 +15,12 @@ async fn main() -> Result<()> { tracing_subscriber::fmt::init(); let config = GatewayConfig::from_env(); + + // Fail fast if JWT secret is not configured + if config.jwt_secret.is_empty() { + bail!("JWT_SECRET environment variable must be set (non-empty) for token validation"); + } + let listen_addr = config.listen_addr.clone(); info!("Starting web-gateway on {}", listen_addr); @@ -27,7 +34,7 @@ async fn main() -> Result<()> { // Start gRPC stream bridge tasks (forward gRPC streams to WebSocket broadcast) start_grpc_stream_bridges(state.trading_channel.clone(), state.ws_broadcast.clone()); - // Build CORS from configured origins + // Build CORS from configured origins with restricted methods/headers let origins: Vec = config .cors_origins .iter() @@ -35,10 +42,14 @@ async fn main() -> Result<()> { .collect(); let cors = CorsLayer::new() .allow_origin(origins) - .allow_methods(tower_http::cors::Any) - .allow_headers(tower_http::cors::Any); + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) + .allow_headers(AllowHeaders::list([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + ])); let app = create_router(state) + .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB max request body .layer(cors) .layer(TraceLayer::new_for_http());