diff --git a/backend/src/main.rs b/backend/src/main.rs index 24ea8f8..68089bf 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -55,6 +55,101 @@ async fn main() { } } +#[cfg(test)] +mod security_headers_tests { + use super::add_security_headers; + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::middleware; + use axum::response::Response; + use axum::routing::get; + use axum::Router; + use tower::ServiceExt; + + #[tokio::test] + async fn security_headers_are_added_by_default() { + let app = Router::new() + .route("/api/ping", get(|| async { "ok" })) + .layer(middleware::map_response(add_security_headers)); + + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + assert_eq!( + res.headers() + .get("x-content-type-options") + .and_then(|v| v.to_str().ok()), + Some("nosniff") + ); + assert_eq!( + res.headers() + .get("x-frame-options") + .and_then(|v| v.to_str().ok()), + Some("DENY") + ); + assert_eq!( + res.headers() + .get("referrer-policy") + .and_then(|v| v.to_str().ok()), + Some("no-referrer") + ); + assert_eq!( + res.headers() + .get("permissions-policy") + .and_then(|v| v.to_str().ok()), + Some("camera=(), microphone=(), geolocation=()") + ); + assert_eq!( + res.headers() + .get("x-permitted-cross-domain-policies") + .and_then(|v| v.to_str().ok()), + Some("none") + ); + } + + #[tokio::test] + async fn security_headers_do_not_override_explicit_headers() { + let app = Router::new() + .route( + "/api/ping", + get(|| async { + Response::builder() + .status(StatusCode::OK) + .header("x-frame-options", "SAMEORIGIN") + .header(header::REFERRER_POLICY, "strict-origin") + .body(Body::from("ok")) + .unwrap() + }), + ) + .layer(middleware::map_response(add_security_headers)); + + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + + assert_eq!( + res.headers() + .get("x-frame-options") + .and_then(|v| v.to_str().ok()), + Some("SAMEORIGIN") + ); + assert_eq!( + res.headers() + .get(header::REFERRER_POLICY) + .and_then(|v| v.to_str().ok()), + Some("strict-origin") + ); + } +} + async fn run() -> Result<(), StartupError> { dotenvy::dotenv().ok(); @@ -349,6 +444,20 @@ async fn add_security_headers(mut res: Response) -> Response { ); } + if !headers.contains_key("permissions-policy") { + headers.insert( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + } + + if !headers.contains_key("x-permitted-cross-domain-policies") { + headers.insert( + HeaderName::from_static("x-permitted-cross-domain-policies"), + HeaderValue::from_static("none"), + ); + } + res } diff --git a/docs/admin/installation.md b/docs/admin/installation.md index 4c79ae0..9253c6b 100644 --- a/docs/admin/installation.md +++ b/docs/admin/installation.md @@ -26,6 +26,7 @@ cp compose/.env.production.example compose/.env.production ``` Required settings: + - `POSTGRES_PASSWORD` - Strong database password - `JWT_SECRET` - Random 64+ character string @@ -38,8 +39,8 @@ podman-compose --env-file .env.production -f production.yml up -d ### 4. Access -- Frontend: http://localhost:4321 -- Backend API: http://localhost:3000 +- Frontend: +- Backend API: ## Manual Installation @@ -76,19 +77,21 @@ node ./dist/server/entry.mjs ## Configuration Files -| File | Purpose | -|------|---------| -| `compose/production.yml` | Production container deployment | -| `compose/demo.yml` | Demo instance deployment | -| `compose/.env.production.example` | Environment template | -| `backend/.env` | Backend configuration | +|File|Purpose| +|---|---| +|`compose/production.yml`|Production container deployment| +|`compose/demo.yml`|Demo instance deployment| +|`compose/.env.production.example`|Environment template| +|`backend/.env`|Backend configuration| ## Reverse Proxy For production, use a reverse proxy (nginx, Caddy) with: + - HTTPS termination - WebSocket support (for real-time features) - Proper headers +- HSTS (set on the reverse proxy) Example nginx config: @@ -107,12 +110,16 @@ server { proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } ``` diff --git a/docs/admin/security.md b/docs/admin/security.md index b70c75e..af11968 100644 --- a/docs/admin/security.md +++ b/docs/admin/security.md @@ -33,6 +33,18 @@ Always use HTTPS in production: - Configure reverse proxy for TLS termination - Enable HSTS headers +### Security Headers + +The backend sets a small set of security headers on responses by default: + +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` +- `Referrer-Policy: no-referrer` +- `Permissions-Policy: camera=(), microphone=(), geolocation=()` +- `X-Permitted-Cross-Domain-Policies: none` + +Set `Strict-Transport-Security` (HSTS) on your reverse proxy, because the backend does not know whether requests arrived via HTTPS. + ### CORS Restrict CORS in production: