@jpgilldev / steid

steid/src/infrastructure/web/security_headers.rs
3.0 KBRaw
1//! Security headers on every response.
2//!
3//! A layer rather than a per-handler concern: a page added without these would simply
4//! not have them, and nothing would fail. The same reasoning `context.rs` gives for
5//! preferring functions over middleware runs the other way here — this is a blanket
6//! property of every response, and the failure mode of forgetting it is silent.
7
8use topcoat::{
9 Result,
10 context::CxBuilder,
11 router::{
12 Body, HeaderValue, Next, Response,
13 header::{
14 CONTENT_SECURITY_POLICY, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
15 },
16 layer,
17 },
18};
19
20/// What the pages are actually allowed to do.
21///
22/// Steid renders **no JavaScript at all** — not a script tag on any page — so
23/// `default-src 'none'` is a policy the product can genuinely keep rather than a wish.
24/// That makes this far stricter than a typical site's CSP, and it is worth preserving:
25/// if a future feature needs script, weakening this is the cost, and it should be a
26/// deliberate decision rather than a quiet edit.
27///
28/// `'unsafe-inline'` for styles only, and only because Topcoat's icon macro emits
29/// `style="vertical-align: -0.125em"` on every icon. Inline *style* without inline
30/// *script* is a much smaller surface, but it is a framework constraint rather than a
31/// choice — a nonce or a hash would be better if Topcoat ever offers one.
32const POLICY: &str = "default-src 'none'; \
33 style-src 'self' 'unsafe-inline'; \
34 img-src 'self' data:; \
35 font-src 'self'; \
36 form-action 'self'; \
37 frame-ancestors 'none'; \
38 base-uri 'none'";
39
40#[layer("/")]
41async fn security_headers(cx: &mut CxBuilder, body: Body, next: Next<'_>) -> Result<Response> {
42 let mut response = next.run(cx, body).await?;
43 let headers = response.headers_mut();
44
45 // `entry().or_insert()`, never `insert()`: the raw-file endpoint sets its own,
46 // stricter `default-src 'none'; sandbox`, and a blanket overwrite here would
47 // quietly relax the one response that most needs locking down.
48 headers
49 .entry(CONTENT_SECURITY_POLICY)
50 .or_insert(HeaderValue::from_static(POLICY));
51
52 headers
53 .entry(X_CONTENT_TYPE_OPTIONS)
54 .or_insert(HeaderValue::from_static("nosniff"));
55
56 // `no-referrer`, not the usual `strict-origin-when-cross-origin`. A private
57 // repository's URL contains its name, and a README may link anywhere; sending the
58 // referrer would hand the name of a private repository to whatever the visitor
59 // clicked. Nothing here needs a referrer for its own sake.
60 headers
61 .entry(REFERRER_POLICY)
62 .or_insert(HeaderValue::from_static("no-referrer"));
63
64 // Redundant with `frame-ancestors` for anything current, kept for browsers that
65 // predate CSP. Cheap, and clickjacking a one-click control like "revoke token"
66 // is the realistic attack it stops.
67 headers
68 .entry(X_FRAME_OPTIONS)
69 .or_insert(HeaderValue::from_static("DENY"));
70
71 Ok(response)
72}