diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 1baf0348..af4706fb 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -731,6 +731,71 @@ fn icon_markup(app: &GatedPort) -> String { format!(r#"
{inner}
"#) } +/// The dashboard login's badge, reproduced square-for-square: the same 20 +/// white rects AnimatedLogo.vue draws, with the same 100ms stagger, inside +/// the same gradient ring. Inline rather than an `` because the shipped +/// `favico-black-v2.svg` bakes its own ring into the artwork — wrapping it in +/// the CSS ring drew a ring inside a ring, which is not what /login shows. +/// (x, y, width, height) as they appear in AnimatedLogo.vue. +const LOGO_RECTS: [(f32, f32, f32, f32); 20] = [ + (357.614, 318.0, 71.007, 70.936), + (436.152, 318.0, 72.082, 70.936), + (515.766, 318.0, 72.082, 70.936), + (595.379, 318.0, 71.007, 70.936), + (595.379, 396.46, 71.007, 72.011), + (673.917, 396.46, 72.083, 72.011), + (278.0, 475.994, 72.083, 72.012), + (357.614, 475.994, 71.007, 72.012), + (436.152, 475.994, 72.082, 72.012), + (515.766, 475.994, 72.082, 72.012), + (595.379, 475.994, 71.007, 72.012), + (673.917, 475.994, 72.083, 72.012), + (278.0, 555.529, 72.083, 70.936), + (357.614, 555.529, 71.007, 70.936), + (595.379, 555.529, 71.007, 70.936), + (673.917, 555.529, 72.083, 70.936), + (357.614, 633.989, 71.007, 72.011), + (436.152, 633.989, 72.082, 72.011), + (515.766, 633.989, 72.082, 72.011), + (595.379, 633.989, 71.007, 72.011), +]; + +fn logo_markup() -> String { + let rects: String = LOGO_RECTS + .iter() + .enumerate() + .map(|(i, (x, y, w, h))| { + format!( + r#""#, + delay = i * 100, + ) + }) + .collect(); + format!( + r##""## + ) +} + +/// The main login's in-button spinner, verbatim from Login.vue. +const SPINNER_SVG: &str = r#""#; + +/// Submit feedback: flip the pressed button into its loading face and stop a +/// second press, exactly as /login does. This is the only script on the page, +/// and the CSP admits it by hash — not `'unsafe-inline'` — so an injected +/// `"#, title = esc(title), app_name = esc(&app.app_name), body = body, + submit_feedback = SUBMIT_FEEDBACK_JS, backgrounds = background_layers(), cycle = LOGIN_BACKGROUNDS.len() as u32 * 9, hold = 100 / LOGIN_BACKGROUNDS.len() as u32, @@ -961,10 +1055,16 @@ button:active {{ transform:translateY(1px); }} // login, on any port or scheme, which is exactly the dashboard. // Anything else — another site embedding it to harvest the node // password — is still refused. + // script-src admits exactly one script, by hash: the submit-feedback + // snippet above. Injected markup (an app name, an error string) still + // cannot execute — its hash would not match. .header( "Content-Security-Policy", - "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ - form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", + format!( + "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ + script-src {hash}; form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", + hash = submit_feedback_csp_hash(), + ), ) .body(Body::from(html)) .expect("static response builds") @@ -975,15 +1075,17 @@ button:active {{ transform:translateY(1px); }} /// password by an unexplained page. fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( - r#" + r#"{logo} {icon}

Sign in to open {name}

This app is protected by your node password.

{err}
- +
"#, + logo = logo_markup(), + spinner = SPINNER_SVG, icon = icon_markup(app), name = esc(&app.app_name), err = error @@ -1004,8 +1106,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon {err}
- +
"#, + spinner = SPINNER_SVG, icon = icon_markup(app), name = esc(&app.app_name), err = error @@ -1200,25 +1303,48 @@ mod tests { } /// The login page must render entirely from the gate's own origin: the - /// CSP allows no external host, so a background or logo that 404s leaves - /// a black page rather than the dashboard's art. + /// CSP allows no external host, so a background that 404s leaves a black + /// page rather than the dashboard's art. The badge itself is inline SVG — + /// the same 20 squares as the dashboard login's AnimatedLogo — so it can + /// never 404 at all. #[tokio::test] async fn login_page_sources_its_art_from_the_gate() { let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); let html = String::from_utf8_lossy(&body).to_string(); - assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg"))); + assert_eq!( + html.matches(r#"class="sq""#).count(), + LOGO_RECTS.len(), + "the badge must draw every AnimatedLogo square inline" + ); for name in LOGIN_BACKGROUNDS { assert!( html.contains(&format!("{GATE_PREFIX}asset/{name}")), "background {name} is not referenced" ); } - // Every referenced asset must be one the gate will actually serve. - // The logo is the sidebar A mark (favico-black-v2.svg) since the - // 2026-08-05 login-page rework — the old wordmark is off the - // allowlist on purpose. - assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions))); + } + + /// The only script the challenge pages may run is the submit-feedback + /// snippet, admitted by hash. The page must carry exactly that script, + /// and the CSP must name its hash — anything injected has a different + /// hash and stays inert. + #[tokio::test] + async fn submit_feedback_script_is_present_and_hash_pinned() { + let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); + let csp = resp.headers()["Content-Security-Policy"] + .to_str() + .unwrap() + .to_string(); + assert!(csp.contains(&format!("script-src {}", submit_feedback_csp_hash()))); + assert!(!csp.contains("script-src 'unsafe-inline'")); + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains(&format!(""))); + // Both button faces render: idle label and the spinner face. + assert!(html.contains(r#"Sign in"#)); + assert!(html.contains("Signing in…")); + assert!(html.contains(r#"class="spin""#)); } /// The allowlist is the whole security boundary for asset serving: the