fix(web): keep generated passwords out of page source

This commit is contained in:
2026-07-17 07:04:46 +08:00
parent e8605f15a2
commit d1e4068636
8 changed files with 44 additions and 11 deletions
+1
View File
@@ -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)
+8
View File
@@ -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}}
+15 -3
View File
@@ -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) {
+15 -4
View File
@@ -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())
}
}
-1
View File
@@ -44,7 +44,6 @@ type webPage struct {
FormAction string
BackURL string
Token string
OneTimeSecret string
Admin bool
UserName string
Email string