fix: deep bug hunt II — concurrency, security & optimization (25 files)
Critical: watchdog mutex panic safety, SetGeoAppOverride nil guard, SSD-only app DB restore fallback. High: double deploy race (atomic Deploying flag), delete/remove during deploy guard, ScanStacks overwrite protection, FileBrowser mount mutex, PushEvent history, PushOnce error handling, DB dump sync+close before rename, restic retry fresh context, encrypt failure logging, cross-backup path traversal validation, deepCopyStack completeness. Security: constant-time API key comparison, login rate limiting (5/min), git credential masking in logs, storage path prefix traversal fix. Concurrency: MigrateEncryption lock ordering, SubdomainInUse I/O outside lock, scheduler late-registered jobs, SQLite WAL verification, metrics shutdown context, telemetry scan error logging, asset sync lock scope. Optimization: streaming file copy for DB dumps, restic stats dedup, atomic infra config copy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,17 @@ type session struct {
|
||||
csrfToken string
|
||||
}
|
||||
|
||||
// loginAttempt tracks failed login attempts for rate limiting.
|
||||
type loginAttempt struct {
|
||||
count int
|
||||
lastFail time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
sessionCookieName = "felhom_session"
|
||||
sessionMaxAge = 7 * 24 * time.Hour
|
||||
sessionCookieName = "felhom_session"
|
||||
sessionMaxAge = 7 * 24 * time.Hour
|
||||
loginMaxAttempts = 5
|
||||
loginWindowDuration = 1 * time.Minute
|
||||
)
|
||||
|
||||
// effectivePasswordHash returns the active password hash using the priority:
|
||||
@@ -98,13 +106,47 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit: check failed attempts from this IP
|
||||
ip := r.RemoteAddr
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
ip = strings.Split(fwd, ",")[0]
|
||||
}
|
||||
ip = strings.TrimSpace(ip)
|
||||
|
||||
s.loginAttemptMu.Lock()
|
||||
attempt := s.loginAttempts[ip]
|
||||
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
|
||||
// Window expired — reset
|
||||
attempt = nil
|
||||
delete(s.loginAttempts, ip)
|
||||
}
|
||||
if attempt != nil && attempt.count >= loginMaxAttempts {
|
||||
s.loginAttemptMu.Unlock()
|
||||
s.logger.Printf("[WARN] Login rate limited for %s (%d attempts)", ip, attempt.count)
|
||||
s.renderLogin(w, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva", "")
|
||||
return
|
||||
}
|
||||
s.loginAttemptMu.Unlock()
|
||||
|
||||
effectiveHash := s.effectivePasswordHash()
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
|
||||
s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr)
|
||||
s.loginAttemptMu.Lock()
|
||||
if s.loginAttempts[ip] == nil {
|
||||
s.loginAttempts[ip] = &loginAttempt{}
|
||||
}
|
||||
s.loginAttempts[ip].count++
|
||||
s.loginAttempts[ip].lastFail = time.Now()
|
||||
s.loginAttemptMu.Unlock()
|
||||
s.renderLogin(w, "Hibás jelszó", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Successful login — clear rate limit for this IP
|
||||
s.loginAttemptMu.Lock()
|
||||
delete(s.loginAttempts, ip)
|
||||
s.loginAttemptMu.Unlock()
|
||||
|
||||
token := s.createSession()
|
||||
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
|
||||
@@ -965,6 +965,23 @@ func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Reque
|
||||
schedule = existing.Schedule
|
||||
}
|
||||
|
||||
// Validate destination path against registered storage paths (H11 fix — matches API handler).
|
||||
if enabled && destPath != "" {
|
||||
registeredPaths := s.settings.GetStoragePaths()
|
||||
validDest := false
|
||||
for _, sp := range registeredPaths {
|
||||
if destPath == sp.Path {
|
||||
validDest = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDest {
|
||||
s.logger.Printf("[WARN] Cross-drive backup: rejected invalid dest path %q for %s", destPath, name)
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error="+url.QueryEscape("Érvénytelen célútvonal: "+destPath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cfg *settings.CrossDriveBackup
|
||||
if destPath != "" || existing != nil {
|
||||
cfg = &settings.CrossDriveBackup{
|
||||
@@ -1543,6 +1560,10 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
|
||||
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
||||
// with volume mounts and sources for all registered storage paths, then recreates the container.
|
||||
func (s *Server) SyncFileBrowserMounts() {
|
||||
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
|
||||
s.fileBrowserMu.Lock()
|
||||
defer s.fileBrowserMu.Unlock()
|
||||
|
||||
stackDir := "/opt/docker/stacks/filebrowser"
|
||||
composePath := stackDir + "/docker-compose.yml"
|
||||
|
||||
|
||||
@@ -41,10 +41,12 @@ type Server struct {
|
||||
encKey []byte // AES-256 key for decrypting app.yaml values
|
||||
tmpl *template.Template
|
||||
|
||||
sessions map[string]*session
|
||||
sessionsMu sync.RWMutex
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
sessions map[string]*session
|
||||
sessionsMu sync.RWMutex
|
||||
loginAttempts map[string]*loginAttempt
|
||||
loginAttemptMu sync.Mutex
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
// Disk operation state (format/migrate jobs)
|
||||
diskJobMu sync.Mutex
|
||||
@@ -53,6 +55,9 @@ type Server struct {
|
||||
// Active raw mount for the attach wizard (empty when not in use)
|
||||
activeRawMount string
|
||||
|
||||
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
|
||||
fileBrowserMu sync.Mutex
|
||||
|
||||
// Drive migration
|
||||
driveMigrator *storage.DriveMigrator
|
||||
|
||||
@@ -90,6 +95,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
||||
logger: logger,
|
||||
version: version,
|
||||
sessions: make(map[string]*session),
|
||||
loginAttempts: make(map[string]*loginAttempt),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
s.loadTemplates()
|
||||
@@ -111,6 +117,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
||||
}
|
||||
|
||||
// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display.
|
||||
// Must be called before ListenAndServe (all Set* methods are init-time only).
|
||||
func (s *Server) SetEncryptionKey(key []byte) {
|
||||
s.encKey = key
|
||||
}
|
||||
|
||||
@@ -952,7 +952,7 @@ func (s *Server) storageAttachBrowseHandler(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// Security: validate path is under the raw mount staging area
|
||||
cleanPath := filepath.Clean(browsePath)
|
||||
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
||||
if cleanPath != storage.RawMountBase && !strings.HasPrefix(cleanPath, storage.RawMountBase+"/") {
|
||||
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user