From d1754ca2d9519eaa78f978594cb81a2562970bca Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 14:20:26 +0000 Subject: dashboard: a content security policy, framed by none Review finding 12, browsers: the dashboard holds a bearer token in memory and edits the document, but had no content security policy and could be framed by any page of the same site (another port on the same host, anything sharing a domain behind a proxy), acting with its user's token. The dashboard and the login page now come with a policy that runs only their own inline script (by its hash, computed when first served), loads nothing from elsewhere, and lets no page frame them (frame-ancestors 'none', X-Frame-Options DENY). Fetches stay open to any origin, as the members' dashboards are wherever the member list says. The browser check (check.js) loads the page from a file, without the header, so the policy wants one look in a browser. --- docs/dashboard.md | 3 ++- src/api/dashboard/mod.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 01507df..db599fb 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -21,7 +21,8 @@ dashboard without one is sent to. There, a user logs in with its password [`access.md`](access.md), Tokens), or any other. The page keeps a session for the browser, a cookie no script reads and no other site's page sends, renewed while the dashboard is open; it ends with the token's lifetime, or with "Log out of this -browser". The API listens on localhost unless `--bind-addr` says otherwise; +browser". The page and the login page run their own script only (a content security +policy naming it by its hash), and no other page may frame them. The API listens on localhost unless `--bind-addr` says otherwise; to look at a node on another machine, forward the port (`ssh -L 3000:localhost:3000 `) or put a TLS proxy in front ([`operations.md`](operations.md), Behind a reverse proxy). diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 24fdacb..d6485d7 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -602,16 +602,52 @@ fn page() -> &'static (String, String) { }) } +/// What a page of ours may do, `page` (whose one script is inline) being it: run its +/// own script and nothing else (an injected one, should a value shown slip through, gets +/// no token it holds), be framed by no other page (`frame-ancestors`: one framing it +/// would have it act with its user's token), and fetch from its node and the members' +/// dashboards (any origin: those the member list names). +fn content_policy(page: &str) -> String { + use base64::{Engine, engine::general_purpose::STANDARD}; + use sha2::{Digest, Sha256}; + let script = page + .split_once("")) + .map_or("", |(script, _)| script); + let hash = STANDARD.encode(Sha256::digest(script.as_bytes())); + format!( + "default-src 'self'; script-src 'sha256-{hash}'; style-src 'self' 'unsafe-inline'; \ + img-src 'self' data:; connect-src 'self' http: https:; frame-ancestors 'none'; \ + base-uri 'none'; form-action 'self'; object-src 'none'" + ) +} + +/// `page`, with its content policy (`content_policy`) and framed by none. +fn guarded(page: &'static str, policy: &'static str) -> Response { + ( + [ + (axum::http::header::CONTENT_SECURITY_POLICY, policy), + (axum::http::header::X_FRAME_OPTIONS, "DENY"), + ], + Html(page), + ) + .into_response() +} + /// `GET /login`: a page to log in on, by password or with a token, without /// authentication (it holds nothing): it keeps a session for the browser /// (`POST /auth/session`) and opens the dashboard. -pub(crate) async fn login_page() -> Html<&'static str> { - Html(include_str!("login.html")) +pub(crate) async fn login_page() -> Response { + const LOGIN: &str = include_str!("login.html"); + static POLICY: OnceLock = OnceLock::new(); + guarded(LOGIN, POLICY.get_or_init(|| content_policy(LOGIN))) } /// `GET /admin/dashboard`: the page. -async fn page_html() -> Html<&'static str> { - Html(page().1.as_str()) +async fn page_html() -> Response { + static POLICY: OnceLock = OnceLock::new(); + let page = page().1.as_str(); + guarded(page, POLICY.get_or_init(|| content_policy(page))) } /// `POST /admin/dashboard/refresh`: a tick now rather than at the next `TICK`, and the @@ -786,6 +822,23 @@ fn changed_for(changed: &str, access: &super::access::Access) -> Option mod tests { use super::*; + /// A page's content policy lets its own inline script run, by its hash, and no + /// other page frame it. + #[test] + fn pages_run_their_own_script_only() { + use base64::{Engine, engine::general_purpose::STANDARD}; + use sha2::{Digest, Sha256}; + let policy = content_policy("

x

"); + let hash = STANDARD.encode(Sha256::digest(b"go()")); + assert!(policy.contains(&format!("script-src 'sha256-{hash}';"))); + assert!(policy.contains("frame-ancestors 'none'")); + // Each page's one script, found. + for page in [PAGE, include_str!("login.html")] { + assert_eq!(page.matches("