mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
fix(security): move account recovery to local CLI, remove unauthenticated reset endpoints
Unauthenticated POST /api/reset-password and /api/reset-account were a remotely exploitable auth-bypass on public-facing deployments. The confirm phrase was embedded in the frontend and echoed back by the API, so it was friction, not authentication: anyone who knew the account email could reset the password, log in, and obtain a valid JWT. Recovery now runs as local CLI commands that operate directly on the database without starting the HTTP server: nofx reset-password --email you@example.com nofx reset-account These require shell/file access to the host, which a remote attacker does not have, so recovery stays safe even when NOFX is exposed to the public internet. - cli.go: new reset-password / reset-account subcommands (hidden password input on a TTY, --password/stdin for scripting, min 8 chars) - main.go: dispatch subcommands before the server starts (backward compatible with the legacy `nofx <dbpath>` arg) - api: remove public /reset-password and /reset-account routes, their handlers, and the public confirm-phrase constants - web: replace the self-service reset form with CLI instructions; drop the AuthContext resetPassword call and the LoginPage reset-account call (en/zh/id) - telegram: refresh the bot allowlist comment
This commit is contained in:
@@ -191,123 +191,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// resetPasswordConfirmPhrase is the friction step for /api/reset-password.
|
||||
// Same security rationale as resetAccountConfirmPhrase — not a cryptographic
|
||||
// check, just a guard against accidental and drive-by triggers.
|
||||
const resetPasswordConfirmPhrase = "I_UNDERSTAND_THIS_RESETS_MY_PASSWORD"
|
||||
|
||||
// handleResetPassword resets the password for the given email.
|
||||
//
|
||||
// SECURITY NOTE: This endpoint is intentionally callable without a JWT — it
|
||||
// IS the recovery path for "forgot password" in the single-user self-hosted
|
||||
// threat model this project targets. A logged-in user changes password via
|
||||
// PUT /api/user/password; this endpoint exists for users who can no longer
|
||||
// log in. Mitigations:
|
||||
//
|
||||
// 1. Requires the confirm phrase (blocks accidental and drive-by triggers).
|
||||
// 2. New password must be ≥ 8 chars.
|
||||
// 3. Authenticated session change is preferred (PUT /api/user/password).
|
||||
//
|
||||
// Operators exposing the API to the public internet should put a reverse-proxy
|
||||
// auth layer in front of /api/reset-password OR set up out-of-band recovery
|
||||
// (email link, OTP) instead of relying on this endpoint.
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "email, new_password (min 8 chars), and confirm are required")
|
||||
return
|
||||
}
|
||||
if req.Confirm != resetPasswordConfirmPhrase {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Confirmation phrase required",
|
||||
"hint": `Body must include {"confirm":"` + resetPasswordConfirmPhrase + `"}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Password processing failed", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.User().UpdatePassword(user.ID, newPasswordHash); err != nil {
|
||||
SafeInternalError(c, "Password update failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User %s password reset via reset endpoint", user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
||||
}
|
||||
|
||||
// resetAccountConfirmPhrase must appear in the request body for /api/reset-account.
|
||||
// This is the single intentional friction step that prevents accidental wipes
|
||||
// from drive-by scripts and crawlers. It is NOT a cryptographic check — anyone
|
||||
// who reads this source can send the phrase. The real safety comes from:
|
||||
//
|
||||
// 1. Wallet keys are NO LONGER auto-adopted by the next registrant
|
||||
// (adoptOrphanRecords was removed). The historical takeover path was:
|
||||
// reset → register → inherit prior wallet → drain. That path is closed.
|
||||
// 2. The destructive action is loud (logged at Warn level).
|
||||
//
|
||||
// Operators who expose the API to the public internet and want stronger
|
||||
// gating can wrap this route with a reverse-proxy auth header check.
|
||||
const resetAccountConfirmPhrase = "I_UNDERSTAND_THIS_DELETES_EVERYTHING"
|
||||
|
||||
// handleResetAccount wipes all users + traders + strategies + AI models +
|
||||
// exchanges, returning the system to uninitialized state.
|
||||
//
|
||||
// SECURITY NOTE: For the single-user, self-hosted threat model this project
|
||||
// targets, this endpoint is intentionally callable without a JWT — the
|
||||
// frontend "forgot account" button must still work after the user forgets
|
||||
// their password. The confirm phrase blocks accidental and drive-by triggers;
|
||||
// the removal of orphan adoption blocks the post-reset takeover. A determined
|
||||
// attacker on a public-facing deployment can still grief by wiping local
|
||||
// state, but they cannot steal funds (everything is deleted, not transferred).
|
||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||
var req struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Confirm != resetAccountConfirmPhrase {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Confirmation phrase required",
|
||||
"hint": `Body must include {"confirm":"` + resetAccountConfirmPhrase + `"}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Wipe ALL records — including wallet keys and exchange credentials.
|
||||
// Preserving them across user identities is what enabled the takeover.
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to reset account", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Warnf("⚠ Account reset performed — all users, traders, strategies, ai_models, exchanges wiped")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "System wiped. All wallet keys and exchange credentials were deleted. Register a fresh account and re-import everything.",
|
||||
})
|
||||
}
|
||||
// NOTE: Password and account recovery used to live here as the public,
|
||||
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
|
||||
// removed because an unauthenticated recovery endpoint is a remotely
|
||||
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
|
||||
// was embedded in the frontend (and echoed back by the API), so it was friction
|
||||
// rather than authentication. Recovery now lives in the local CLI
|
||||
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
|
||||
// shell access to the host — something a remote attacker does not have.
|
||||
|
||||
// initUserDefaultConfigs Initialize default configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||
|
||||
Reference in New Issue
Block a user