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:
tinkle-community
2026-06-05 10:49:21 +08:00
parent 2d32a8f6c9
commit 577a0918c3
11 changed files with 335 additions and 389 deletions

View File

@@ -33,10 +33,6 @@ interface AuthContextType {
betaCode?: string,
mode?: UserMode
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
newPassword: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
}
@@ -259,36 +255,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}
const resetPassword = async (email: string, newPassword: string) => {
try {
const response = await fetch('/api/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
new_password: newPassword,
// Server-side guard against accidental/drive-by triggers.
// Phrase must match handler_user.go resetPasswordConfirmPhrase.
confirm: 'I_UNDERSTAND_THIS_RESETS_MY_PASSWORD',
}),
})
const data = await response.json()
if (response.ok) {
return { success: true, message: data.message }
} else {
return { success: false, message: data.error }
}
} catch (error) {
return {
success: false,
message: 'Password reset failed, please try again',
}
}
}
// NOTE: in-browser password reset was removed. Recovery now runs as a local
// CLI command on the server (`nofx reset-password`), so it cannot be triggered
// remotely. The reset-password page shows the operator how to run it.
const logout = () => {
const savedToken = localStorage.getItem('auth_token')
@@ -316,7 +285,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
loginAdmin,
register,
resetPassword,
logout,
isLoading,
}}