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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-22 08:34:37 +01:00
parent bd3f558e5a
commit 544315e351
6 changed files with 83 additions and 12 deletions

View File

@@ -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,
},
},
});

View File

@@ -20,7 +20,10 @@ export class ErrorBoundary extends Component<Props, State> {
}
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() {

View File

@@ -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<T>(

View File

@@ -10,12 +10,21 @@ export class WsManager {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private backoff = 1000;
private maxBackoff = 30000;
private maxAttempts = 50;
private attempts = 0;
private subscribedTopics: Set<string> = 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(() => {

View File

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

View File

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