diff options
| -rw-r--r-- | docs/dashboard.md | 3 | ||||
| -rw-r--r-- | 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 <machine>`) 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("<script>") + .and_then(|(_, rest)| rest.split_once("</script>")) + .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<String> = 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<String> = 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<String> 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("<p>x</p><script>go()</script>"); + 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("<script>").count(), 1); + assert!(!content_policy(page).contains(&STANDARD.encode(Sha256::digest(b"")))); + } + } + /// The page in a browser (`check.js`): it loads without errors, the peers table and /// the map draw what the state says, and a large map doesn't hold the page up. /// Needs Node.js with Playwright's library and its Chromium (`check.nix` gives them), |
