fix(web): keep generated passwords out of page source
This commit is contained in:
parent
e8605f15a2
commit
d1e4068636
|
|
@ -240,8 +240,9 @@ users, devices, vaults, storage, audit, SMTP settings, and diagnostics. Lists
|
|||
use bounded server-side search, filters, whitelisted sort order, and pagination.
|
||||
Administrators can create, edit, confirm, block, reset, and delete users; revoke
|
||||
devices; and permanently remove only a previously revoked device. A browser
|
||||
password reset generates a random password and exposes it once on a `no-store`
|
||||
page; it is never placed in the URL, audit log, cookies, or database plaintext.
|
||||
password reset generates a random password and exposes it once through a
|
||||
CSRF-protected `no-store` POST after the result page loads; it is never placed
|
||||
in the URL, initial HTML source, audit log, cookies, or database plaintext.
|
||||
Destructive
|
||||
browser actions use the shared local confirmation dialog. Blocking, credential
|
||||
changes, device actions, cleanup, and SMTP changes require the current
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func (s *Server) routes() {
|
|||
s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout)
|
||||
s.mux.HandleFunc("/admin/action", s.handleAdminWebAction)
|
||||
s.mux.HandleFunc("/admin/password-result", s.handleAdminPasswordResult)
|
||||
s.mux.HandleFunc("/admin/password-result/secret", s.handleAdminPasswordResultSecret)
|
||||
s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb)
|
||||
s.mux.HandleFunc("/admin/users", s.handleAdminWeb)
|
||||
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb)
|
||||
|
|
|
|||
|
|
@ -36,3 +36,11 @@ document.addEventListener("click", async function (event) {
|
|||
// The downloadable JSON link remains available when clipboard access is unavailable.
|
||||
}
|
||||
});
|
||||
|
||||
const oneTimeSecret = document.querySelector("[data-one-time-secret-url]");
|
||||
if (oneTimeSecret) {
|
||||
fetch(oneTimeSecret.dataset.oneTimeSecretUrl, { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": oneTimeSecret.dataset.csrfToken } })
|
||||
.then(async function (response) { if (!response.ok) throw new Error("one-time secret unavailable"); return response.json(); })
|
||||
.then(function (data) { oneTimeSecret.querySelector(".one-time-secret").textContent = data.password; })
|
||||
.catch(function () { oneTimeSecret.querySelector(".one-time-secret").textContent = "—"; });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content narrow-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.resultTitle"}}</p><h1>{{t .Locale "admin.resetPassword"}}</h1>
|
||||
<section class="card panel warning"><p>{{t .Locale "admin.oneTimePasswordNotice"}}</p><code class="one-time-secret">{{.OneTimeSecret}}</code><p>{{t .Locale "admin.oneTimePasswordHint"}}</p></section>
|
||||
<section class="card panel warning" data-one-time-secret-url="/admin/password-result/secret" data-csrf-token="{{.CSRF}}"><p>{{t .Locale "admin.oneTimePasswordNotice"}}</p><code class="one-time-secret" aria-live="polite">{{t .Locale "common.loading"}}</code><p>{{t .Locale "admin.oneTimePasswordHint"}}</p></section>
|
||||
<a class="button secondary" href="/admin/users">{{t .Locale "admin.users"}}</a>
|
||||
</div></section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -62,18 +62,30 @@ func (s *Server) handleAdminPasswordResult(w http.ResponseWriter, r *http.Reques
|
|||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPasswordResultSecret(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
if !s.requireAdminMutation(w, r) {
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie("admin_session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
jsonErrCode(w, http.StatusForbidden, "session_invalid", "administrator session is required")
|
||||
return
|
||||
}
|
||||
secret := s.takeAdminOneTimeSecret(cookie.Value)
|
||||
if secret == "" {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
jsonErrCode(w, http.StatusGone, "one_time_secret_expired", "one-time password is no longer available")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users", OneTimeSecret: secret})
|
||||
jsonOK(w, map[string]string{"password": secret})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -650,15 +650,26 @@ func TestAdminPasswordResetShowsGeneratedSecretOnce(t *testing.T) {
|
|||
resultRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
||||
resultResponse := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(resultResponse, resultRequest)
|
||||
if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "one-time-secret") {
|
||||
if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "data-one-time-secret-url") {
|
||||
t.Fatalf("password result=%d headers=%v body=%s", resultResponse.Code, resultResponse.Header(), resultResponse.Body.String())
|
||||
}
|
||||
secondRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
|
||||
secretRequest := httptest.NewRequest(http.MethodPost, "/admin/password-result/secret", nil)
|
||||
secretRequest.Header.Set("X-CSRF-Token", csrf)
|
||||
secretRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
||||
secretRequest.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
||||
secretResponse := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(secretResponse, secretRequest)
|
||||
if secretResponse.Code != http.StatusOK || !strings.Contains(secretResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(secretResponse.Body.String(), `"password"`) {
|
||||
t.Fatalf("secret response=%d headers=%v body=%s", secretResponse.Code, secretResponse.Header(), secretResponse.Body.String())
|
||||
}
|
||||
secondRequest := httptest.NewRequest(http.MethodPost, "/admin/password-result/secret", nil)
|
||||
secondRequest.Header.Set("X-CSRF-Token", csrf)
|
||||
secondRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
||||
secondRequest.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
||||
secondResponse := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(secondResponse, secondRequest)
|
||||
if secondResponse.Code != http.StatusSeeOther || secondResponse.Header().Get("Location") != "/admin/users" {
|
||||
t.Fatalf("second password result=%d %q", secondResponse.Code, secondResponse.Header().Get("Location"))
|
||||
if secondResponse.Code != http.StatusGone {
|
||||
t.Fatalf("second password result=%d: %s", secondResponse.Code, secondResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ type webPage struct {
|
|||
FormAction string
|
||||
BackURL string
|
||||
Token string
|
||||
OneTimeSecret string
|
||||
Admin bool
|
||||
UserName string
|
||||
Email string
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ await toggleTemporaryUser();
|
|||
// process so the subsequent user login exercises the generated credential.
|
||||
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'reset-user-password'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
|
||||
await confirmDialog("/admin/password-result");
|
||||
await waitFor(() => evaluate("!['Загрузка...', 'Loading...', '—'].includes(document.querySelector('.one-time-secret')?.textContent.trim())"), "generated one-time password");
|
||||
await screenshot("admin-password-result");
|
||||
const generatedPassword = await evaluate("document.querySelector('.one-time-secret')?.textContent.trim()");
|
||||
if (!generatedPassword || (await evaluate("location.href")).includes(generatedPassword)) throw new Error("one-time password result is missing or leaked into the URL");
|
||||
|
|
|
|||
Loading…
Reference in New Issue