From 6c2395800c74084de957dd28a3e3ffe800b3a1d4 Mon Sep 17 00:00:00 2001 From: Bojay Liu <189326887+BojayL@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:05:35 +0800 Subject: [PATCH] Add API security headers --- crates/api/src/main.rs | 1 + crates/api/src/middleware.rs | 69 ++++++++++++++++++++++++++++++++---- crates/api/src/routes/mod.rs | 2 +- docs/security-checklist.md | 1 + 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index e022609..3956e1e 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -44,6 +44,7 @@ async fn main() -> anyhow::Result<()> { ), ) .layer(RequestBodyLimitLayer::new(1024 * 1024)) + .layer(axum::middleware::from_fn(middleware::security_headers)) .layer(TraceLayer::new_for_http()); let port = std::env::var("PORT") diff --git a/crates/api/src/middleware.rs b/crates/api/src/middleware.rs index 382733f..6de196f 100644 --- a/crates/api/src/middleware.rs +++ b/crates/api/src/middleware.rs @@ -2,7 +2,7 @@ use axum::{ body::Body, extract::State, - http::{Request, StatusCode}, + http::{HeaderName, HeaderValue, Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, Json, @@ -41,6 +41,30 @@ pub async fn require_admin_key( next.run(req).await } +pub async fn security_headers(req: Request, next: Next) -> Response { + let mut response = next.run(req).await; + let headers = response.headers_mut(); + + headers.insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + headers.insert( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + ); + headers.insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("frame-ancestors 'none'"), + ); + + response +} + #[cfg(test)] mod tests { use super::*; @@ -63,12 +87,15 @@ mod tests { api_port: 8081, }; - let app = Router::new() - .route("/admin", get(|| async { "ok" })) - .layer(axum::middleware::from_fn_with_state( - AppState::new(config, sqlx::PgPool::connect_lazy("postgres://localhost/waveflow").unwrap()), + let app = Router::new().route("/admin", get(|| async { "ok" })).layer( + axum::middleware::from_fn_with_state( + AppState::new( + config, + sqlx::PgPool::connect_lazy("postgres://localhost/waveflow").unwrap(), + ), require_admin_key, - )); + ), + ); let response = app .oneshot( @@ -82,4 +109,34 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + + #[tokio::test] + async fn applies_security_headers_without_changing_json_body() { + let app = Router::new() + .route("/json", get(|| async { Json(json!({ "status": "ok" })) })) + .layer(axum::middleware::from_fn(security_headers)); + + let response = app + .oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("x-content-type-options").unwrap(), + "nosniff" + ); + assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY"); + assert_eq!( + response.headers().get("referrer-policy").unwrap(), + "no-referrer" + ); + assert_eq!( + response.headers().get("content-security-policy").unwrap(), + "frame-ancestors 'none'" + ); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], br#"{"status":"ok"}"#); + } } diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 5bb80ef..5be2117 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -11,7 +11,7 @@ use axum::{ use metrics::counter; use serde_json::json; use uuid::Uuid; -use waveflow_shared::{ContributorRecord, PayoutRecord, ProgramRecord, ProgramStatus, WaveFlowError, WaveFlowResult}; +use waveflow_shared::{ContributorRecord, PayoutRecord, ProgramRecord, ProgramStatus, WaveFlowError}; use crate::state::AppState; diff --git a/docs/security-checklist.md b/docs/security-checklist.md index 70b3f4f..640636e 100644 --- a/docs/security-checklist.md +++ b/docs/security-checklist.md @@ -18,6 +18,7 @@ - [ ] Configure non-default `API_ADMIN_KEYS` - [ ] Terminate TLS at load balancer - [ ] Rate-limit public endpoints +- [x] Send `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and frame-ancestor CSP headers on API responses ## Contract operations