aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 11:59:44 +0000
committertv <tv@krebsco.de>2026-11-09 13:04:46 +0100
commitc9cc9bc0cb03ce6471804f0e66967d94978898ee (patch)
tree3e9f4300279ffb929788d32d98983bdd559b0d7a /src
parent00b80d182363e03d447be0a6c1812ec46f8faee1 (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')
-rw-r--r--src/api/auth.rs19
-rw-r--r--src/api/dashboard/dashboard.html14
-rw-r--r--src/api/dashboard/mod.rs8
-rw-r--r--src/api/mod.rs15
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")]
{