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 | |
| 8 | use 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. |
| 32 | const 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("/")] |
| 41 | async 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 | } |