safety(web-gateway,web-dashboard): security headers, server-side validation, prod build hardening
- Add HTTP security headers to all responses: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, X-XSS-Protection: 0, Referrer-Policy: strict-origin-when-cross-origin, Strict-Transport-Security: max-age=31536000 - Add server-side input validation to submit_order: symbol format, side/order_type enum range, quantity range, price positivity, limit orders require price — 12 new tests - Disable sourcemaps in production builds (vite.config.ts) - Add global unhandled promise rejection handler (main.tsx) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
sourcemap: process.env.NODE_ENV !== 'production',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -71,11 +71,81 @@ struct SubmitOrderBody {
|
||||
client_order_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<AppState>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Json(body): Json<SubmitOrderBody>,
|
||||
) -> Result<Json<serde_json::Value>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user