diff options
| author | tv <tv@krebsco.de> | 2026-11-09 11:59:44 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-09 13:04:46 +0100 |
| commit | c9cc9bc0cb03ce6471804f0e66967d94978898ee (patch) | |
| tree | 3e9f4300279ffb929788d32d98983bdd559b0d7a /src/api | |
| parent | 00b80d182363e03d447be0a6c1812ec46f8faee1 (diff) | |
dashboard: going to another node's dashboard without its login page in between
Going to another member's dashboard showed its login page for a moment: a browser
without a session there was sent from `/admin/dashboard` to `/login` (by
`auth::basic_auth`), which began a session with the token handed over and went on to
the dashboard.
Now the dashboard's page is served without authentication, as the login page is (it
holds nothing: what it shows it reads with the browser's session). Opened with a token
handed over, the page begins the session with it (`POST /auth/session`) before it reads
anything; without a session, `obtainTokens` goes to the login page, as the redirect
did. A request without credentials is refused at once rather than after the 1.5 s
that slow down guessing passwords: nothing is guessed there, and a page without a
session goes to the login page without waiting twice for it.
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/auth.rs | 19 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 14 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 8 | ||||
| -rw-r--r-- | src/api/mod.rs | 15 |
4 files changed, 36 insertions, 20 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs index abb8c1a..fb03ea1 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -308,21 +308,10 @@ pub(crate) async fn basic_auth( .map(|user| (user, Via("htpasswd"))), }; let Some((user, via)) = user else { - sleep(Duration::from_millis(1500)).await; - // A browser opening the dashboard: to the page to log in on. - if cfg!(feature = "dashboard") - && request.method() == axum::http::Method::GET - && request.uri().path() == "/admin/dashboard" - && headers - .get(axum::http::header::ACCEPT) - .and_then(|accept| accept.to_str().ok()) - .is_some_and(|accept| accept.contains("text/html")) - { - return ( - StatusCode::SEE_OTHER, - [(axum::http::header::LOCATION, "../login")], - ) - .into_response(); + // Slowing down guessing: only where credentials came (none: nothing guessed, and + // a page without a session goes to the login page at once). + if headers.contains_key(AUTHORIZATION) { + sleep(Duration::from_millis(1500)).await; } // A challenge only where basic authentication can answer it, for the API (curl, // which sends the password unasked with `-u` anyway), never to a browser: it would diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index d32fa7a..dd37f29 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -4374,7 +4374,19 @@ remakeForm.addEventListener("submit", event => { .catch(error => remakeResult("Proposing failed: " + error, true)); }); -connect(); +// Opened from another member's page (`handOver`) with a token: it begins this browser's +// session here before anything is read, with no login page in between. Without a +// session, `obtainTokens` goes to the login page. +(async () => { + if (handOver && typeof handOver.token === "string") { + try { + await tokenCall("../auth/session", handOver.token); + } catch (_) { /* a session of this browser's own may let it in still */ } + delete handOver.token; + } + connect(); + obtainTokens(); +})(); // A page not shown (another tab, a minimised window) is paused: hidden for a while, it // closes its stream, so the node stops building updates and asking members for their // links for it; shown again, it reconnects. Briefly hidden, nothing changes. diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 3a50ca6..9cf88a1 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -498,7 +498,6 @@ pub(crate) fn router(state: AppState) -> Router<AppState> { // Its streams end when the node shuts down (`AppState::closing`). let closing = state.closing.subscribe(); Router::new() - .route("/admin/dashboard", get(page_html)) .route("/admin/dashboard/state", get(current_state)) .route("/admin/dashboard/events", get(events)) .route("/admin/dashboard/refresh", post(refresh)) @@ -701,8 +700,11 @@ pub(crate) async fn login_page() -> Response { guarded(LOGIN, POLICY.get_or_init(|| content_policy(LOGIN))) } -/// `GET /admin/dashboard`: the page. -async fn page_html() -> Response { +/// `GET /admin/dashboard`: the page, without authentication, as the login page (it holds +/// nothing; what it shows it reads with the browser's session, and without one it goes +/// to `/login`). So a page opened from another member's comes up at once, beginning a +/// session with the token handed over, with no login page in between. +pub(crate) 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))) diff --git a/src/api/mod.rs b/src/api/mod.rs index 437f932..6631bc9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -137,8 +137,10 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .route("/auth/challenge", axum::routing::post(login::challenge)) .route("/auth/login/key", axum::routing::post(login::login_key)) .route("/health", get(health::node_health)) - // To log in to the dashboard on, without authentication. + // To log in to the dashboard on, and the dashboard's page, without authentication + // (they hold nothing). .route("/login", get(dashboard_login)) + .route("/admin/dashboard", get(dashboard_page)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. .layer(middleware::from_fn(json_errors)) @@ -283,6 +285,17 @@ async fn json_errors(request: Request, next: Next) -> Response { } /// `GET /login` (`dashboard::login_page`); without the dashboard, none. +async fn dashboard_page() -> Response { + #[cfg(feature = "dashboard")] + { + dashboard::page_html().await + } + #[cfg(not(feature = "dashboard"))] + { + StatusCode::NOT_FOUND.into_response() + } +} + async fn dashboard_login() -> Response { #[cfg(feature = "dashboard")] { |
