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:
jgrusewski
2026-02-22 09:27:29 +01:00
parent cab4fcda4c
commit 71d988e5f1
4 changed files with 220 additions and 2 deletions

View File

@@ -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 />

View File

@@ -13,7 +13,7 @@ export default defineConfig({
},
},
build: {
sourcemap: true,
sourcemap: process.env.NODE_ENV !== 'production',
rollupOptions: {
output: {
manualChunks: {

View File

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

View File

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