Compare commits

...

1 Commits

Author SHA1 Message Date
simianastronaut
9c29f18f0f fix(gateway): add security headers to all HTTP responses (#8)
Add defense-in-depth security headers via axum middleware layer applied
to every gateway response, closing CWE-352 gap identified in issue #8:

- X-Content-Type-Options: nosniff (prevents MIME-type sniffing)
- X-Frame-Options: DENY (prevents clickjacking)
- Cache-Control: no-store (prevents caching of sensitive API responses)
- Content-Security-Policy: default-src 'none' (restrictive CSP)

Implemented as an axum map_response middleware so all routes — webhooks,
API, health, metrics, SSE, WebSocket upgrade, and static assets — get
the headers without per-handler changes.

Closes #8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:01:37 -04:00

View File

@ -29,8 +29,9 @@ use anyhow::{Context, Result};
use axum::{
body::Bytes,
extract::{ConnectInfo, Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Json},
http::{header, HeaderMap, HeaderValue, StatusCode},
middleware,
response::{IntoResponse, Json, Response},
routing::{delete, get, post, put},
Router,
};
@ -710,6 +711,8 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(REQUEST_TIMEOUT_SECS),
))
// ── Security headers on every response (CWE-352 / defense-in-depth) ──
.layer(middleware::map_response(security_headers))
// ── SPA fallback: non-API GET requests serve index.html ──
.fallback(get(static_files::handle_spa_fallback));
@ -727,6 +730,33 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
Ok(())
}
// ══════════════════════════════════════════════════════════════════════════════
// SECURITY HEADERS MIDDLEWARE
// ══════════════════════════════════════════════════════════════════════════════
/// Middleware that injects security headers into every HTTP response.
///
/// Headers added:
/// - `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing
/// - `X-Frame-Options: DENY` — prevents clickjacking via iframes
/// - `Cache-Control: no-store` — prevents caching of API responses
/// - `Content-Security-Policy: default-src 'none'` — restrictive CSP for API responses
async fn security_headers(response: Response) -> Response {
let mut response = response;
let headers = response.headers_mut();
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'"),
);
response
}
// ══════════════════════════════════════════════════════════════════════════════
// AXUM HANDLERS
// ══════════════════════════════════════════════════════════════════════════════
@ -1667,6 +1697,43 @@ mod tests {
assert_eq!(REQUEST_TIMEOUT_SECS, 30);
}
#[tokio::test]
async fn security_headers_are_injected() {
let inner = axum::http::Response::builder()
.status(200)
.body(axum::body::Body::from("ok"))
.unwrap();
let response = security_headers(inner).await;
assert_eq!(
response
.headers()
.get(header::X_CONTENT_TYPE_OPTIONS)
.map(|v| v.to_str().unwrap()),
Some("nosniff"),
);
assert_eq!(
response
.headers()
.get(header::X_FRAME_OPTIONS)
.map(|v| v.to_str().unwrap()),
Some("DENY"),
);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.map(|v| v.to_str().unwrap()),
Some("no-store"),
);
assert_eq!(
response
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.map(|v| v.to_str().unwrap()),
Some("default-src 'none'"),
);
}
#[test]
fn webhook_body_requires_message_field() {
let valid = r#"{"message": "hello"}"#;