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:
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user