From 63d0183dd2336cb48cb54b5d9d4e7434d6401406 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 3 Aug 2026 16:45:32 -0400 Subject: [PATCH 01/10] fix(ui): stop the dashboard cards leaving a backdrop-filter seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vertical line crossing both dashboard cards, appearing at random on hover and hard to catch deliberately. Diagnosed from the screenshot rather than by reproduction. Decoding it and scanning column by column found a lone brightness step at CSS x=633 that never returns — every legitimate container edge in the page shows up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850, 875, 1437), so an unpaired one is not a border. Sampling by region placed it inside the cards and nowhere else: 10/13 rows inside My Apps, 11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same screen x in both cards, which means the boundary lives in screen space and cuts whatever backdrop-filter surface it crosses. style.css already neutralises backdrop-filter for the shared glass classes inside the dashboard's animated perspective/scroll containers, because Chromium/Brave mis-rasterise it there — that block was written for the black-rectangle corruption. `.home-card-shell` declares its own `backdrop-filter: blur(18px)` in Home.vue and was never added to the list, so it was the only unmitigated blur surface on the dashboard. That is exactly the set of pixels the seam appears in. A hover repaint re-rasterises part of the backdrop, and the refreshed half meets the stale half at the damage boundary. Adding it to the existing list also makes the shell consistent with the tiles beside it: its fill is already rgba(0,0,0,0.65), the same as .glass-card, which renders unblurred here. The list is hand-maintained, which is how this shipped — a component declaring backdrop-filter in its own +
{body}
"#, + title = esc(title), + app_name = esc(&app.app_name), + body = body, + ); + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + // The gate answers on the app's own port for an unauthenticated + // caller; nothing here should be cached or framed. + .header(header::CACHE_CONTROL, "no-store") + .header("X-Frame-Options", "DENY") + .header( + "Content-Security-Policy", + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'", + ) + .body(Body::from(html)) + .expect("static response builds") +} + +/// The challenge. Names and pictures the app being opened, so the visitor can +/// confirm what they are authenticating to rather than being asked for a +/// password by an unexplained page. +fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { + let body = format!( + r#"{icon} +

Sign in to open {name}

+

This app is protected by your node password.

+{err} +
+ + +
"#, + icon = icon_markup(app), + name = esc(&app.app_name), + err = error.map(|e| format!(r#"
{}
"#, esc(e))).unwrap_or_default(), + prefix = GATE_PREFIX, + ); + page("Sign in", app, &body, status) +} + +/// Second factor. Reached only after the password verified, and the session +/// backing it cannot authorise anything until this completes. +fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { + let body = format!( + r#"{icon} +

Two-factor code

+

Enter the 6-digit code to open {name}.

+{err} +
+ + +
"#, + icon = icon_markup(app), + name = esc(&app.app_name), + err = error.map(|e| format!(r#"
{}
"#, esc(e))).unwrap_or_default(), + prefix = GATE_PREFIX, + ); + page("Two-factor", app, &body, status) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> GatedPort { + GatedPort { + port: 8090, + app_id: "strfry".to_string(), + app_name: "Strfry Relay".to_string(), + icon: None, + } + } + + #[test] + fn bearer_token_is_parsed_case_insensitively() { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap()); + assert_eq!(bearer_token(&headers), Some("abc123".to_string())); + + headers.insert(header::AUTHORIZATION, "bearer abc123".parse().unwrap()); + assert_eq!(bearer_token(&headers), Some("abc123".to_string())); + } + + #[test] + fn non_bearer_authorization_is_ignored() { + let mut headers = HeaderMap::new(); + // An app's own Basic credential must never be mistaken for ours. + headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap()); + assert_eq!(bearer_token(&headers), None); + headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap()); + assert_eq!(bearer_token(&headers), None); + } + + #[tokio::test] + async fn login_page_names_the_app() { + let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("Sign in to open Strfry Relay")); + // A lettermark stands in when the manifest declares no icon. + assert!(html.contains("lettermark")); + } + + #[tokio::test] + async fn page_escapes_app_names() { + let mut app = app(); + app.app_name = r#""#.to_string(); + 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); + assert!(!html.contains("