diff options
Diffstat (limited to 'src/api/dashboard')
| -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), |
