aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/mod.rs61
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),