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 | |
| 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.
| -rw-r--r-- | docs/dashboard.md | 10 | ||||
| -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 |
5 files changed, 42 insertions, 24 deletions
diff --git a/docs/dashboard.md b/docs/dashboard.md index 4973227..4948c03 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -15,8 +15,9 @@ cargo build --release --features mycelium,dashboard It is at `/admin/dashboard` on the node's HTTP API, by default `http://127.0.0.1:3000/admin/dashboard`, for a user a reverse proxy names -(`--user-header`) or one logged in on the page `/login`, which a browser opening the -dashboard without one is sent to. There, a user logs in with its password +(`--user-header`) or one logged in on the page `/login`, which the dashboard's page +(served to anyone: it holds nothing, and reads what it shows with the browser's +session) goes to at once without one. There, a user logs in with its password (`--htpasswd`, the same file the API's basic authentication takes, which the dashboard never asks for itself, nor takes from the browser: no browser password prompt, the node never challenges a browser), or with a token: from `continuum-signed login` (by key, @@ -258,8 +259,9 @@ measured. (`--dashboard-url`, in the member list; e.g. behind a reverse proxy, [`operations.md`](operations.md)). A token from this node goes along, reading the member list only, for five minutes, enough for the map: where the browser has no - session at the other dashboard, its login page begins one with it (log in there to - see more), unless its proxy asks for credentials of its own. Whoever publishes the + session at the other dashboard, the page there begins one with it before it reads + anything, with no login page in between (log in there to see more), unless its proxy + asks for credentials of its own. Whoever publishes the dashboard (a member writes its own URL) gets that token, no more. It goes in the address's fragment, which the other page takes out at once; where that page never loads (an error, a proxy's own login), it stays in the address bar and the history, 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")] { |
