security: add default security headers

This commit is contained in:
Marco Allegretti 2026-02-12 13:41:51 +01:00
parent d381478b29
commit 51a78b1eb4
3 changed files with 136 additions and 8 deletions

View file

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

View file

@ -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: <http://localhost:4321>
- Backend API: <http://localhost:3000>
## 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;
}
}
```

View file

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