From b037225a0546f4396e138c7f7ad4d56107f502d0 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 11 Aug 2026 22:47:55 +0200 Subject: [PATCH] [fix](trx-frontend-http): block Guest password changes --- .../assets/web/generated/app.js | 8 ++++++ .../trx-frontend-http/frontend/src/app.ts | 8 ++++++ .../frontend/tests/account-management.mjs | 11 ++++++++ .../trx-frontend-http/src/auth.rs | 26 ++++++++++++++++++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 351d3412..281db0be 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -6097,6 +6097,14 @@ async function refreshUserManagement() { password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024; + const syncPasswordAccess = () => { + const guestActive = guestInput?.checked === true; + password.disabled = guestActive; + password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : ""; + if (guestActive) password.value = ""; + }; + roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess)); + syncPasswordAccess(); const save = document.createElement("button"); save.type = "button"; save.textContent = "Save"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 830be597..3cee2585 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -5173,6 +5173,14 @@ async function refreshUserManagement() { } const password = document.createElement("input"); password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024; + const syncPasswordAccess = () => { + const guestActive = guestInput?.checked === true; + password.disabled = guestActive; + password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : ""; + if (guestActive) password.value = ""; + }; + roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess)); + syncPasswordAccess(); const save = document.createElement("button"); save.type = "button"; save.textContent = "Save"; save.addEventListener("click", async () => { const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs index bd7b052a..1ae062ca 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs @@ -18,6 +18,7 @@ const fixture = await startWebFixture({ }, users: [ { username: "admin", roles: ALL_ROLES, enabled: true }, + { username: "guest", roles: ["guest"], enabled: true }, { username: "listener", roles: ["read"], enabled: false }, ], }); @@ -41,6 +42,7 @@ try { const rows = [...document.querySelectorAll("#user-list > .sch-row")]; const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username)); const admin = rowFor("admin"); + const guest = rowFor("guest"); const listener = rowFor("listener"); const role = (row, value) => row?.querySelector(`input[value="${value}"]`); return { @@ -49,6 +51,7 @@ try { adminRoleLocked: role(admin, "administrator")?.disabled, adminGuestLocked: role(admin, "guest")?.disabled, adminRemoveLocked: admin?.querySelector("button.danger")?.disabled, + guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled, listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked, listenerRead: role(listener, "read")?.checked, }; @@ -58,8 +61,16 @@ try { assert.equal(state.adminRoleLocked, true); assert.equal(state.adminGuestLocked, true); assert.equal(state.adminRemoveLocked, true); + assert.equal(state.guestPasswordLocked, true); assert.equal(state.listenerEnabled, false); assert.equal(state.listenerRead, true); + + const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" }); + const listenerPassword = listenerRow.locator('input[type="password"]'); + await listenerRow.locator('input[value="guest"]').check(); + assert.equal(await listenerPassword.isDisabled(), true); + await listenerRow.locator('input[value="read"]').check(); + assert.equal(await listenerPassword.isDisabled(), false); assert.deepEqual(runtimeErrors, []); } finally { await browser.close(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs index ce291e95..417d599f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs @@ -514,6 +514,10 @@ impl UserStore { roles: Option, enabled: Option, ) -> Result<(), UserStoreError> { + let roles = roles + .map(validate_roles) + .transpose() + .map_err(UserStoreError::Invalid)?; if let Some(value) = password { validate_password(value).map_err(UserStoreError::Invalid)?; } @@ -526,6 +530,16 @@ impl UserStore { .get(username) .cloned() .ok_or(UserStoreError::NotFound)?; + if password.is_some() + && roles + .as_ref() + .unwrap_or(&old.roles) + .contains(&AuthRole::Guest) + { + return Err(UserStoreError::Invalid( + "password changes are not available while the guest role is active".to_string(), + )); + } let remains_enabled_administrator = enabled.unwrap_or(old.enabled) && roles .as_ref() @@ -544,7 +558,7 @@ impl UserStore { record.password_hash = hash; } if let Some(value) = roles { - record.roles = validate_roles(value).map_err(UserStoreError::Invalid)?; + record.roles = value; } if let Some(value) = enabled { record.enabled = value; @@ -1666,6 +1680,16 @@ mod tests { assert_eq!(response.status(), actix_web::http::StatusCode::OK); let admin_cookie = response.response().cookies().next().unwrap().to_string(); + let request = aw_test::TestRequest::patch() + .uri("/auth/users/guest") + .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone())) + .set_json(serde_json::json!({"password":"changed-guest-password"})) + .to_request(); + assert_eq!( + aw_test::call_service(&app, request).await.status(), + actix_web::http::StatusCode::BAD_REQUEST + ); + let request = aw_test::TestRequest::post() .uri("/auth/users") .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))