diff options
| author | tv <tv@krebsco.de> | 2026-11-08 14:20:26 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 15:33:31 +0100 |
| commit | d1754ca2d9519eaa78f978594cb81a2562970bca (patch) | |
| tree | 89a72ff7b3a1645eee9005484d1c967fb66fd3e2 /src/api/dashboard/mod.rs | |
| parent | d56b3b069782585a6edcc9c608236361c2644a51 (diff) | |
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.
Diffstat (limited to 'src/api/dashboard/mod.rs')
| -rw-r--r-- | src/api/dashboard/mod.rs | 61 |
1 files changed, 57 insertions, 4 deletions
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), |
