Skip to content

Commit 80b10c5

Browse files
committed
fix: enhance security in backup handling and snapshot validation
1 parent b568189 commit 80b10c5

4 files changed

Lines changed: 47 additions & 22 deletions

File tree

gearbox-agent/internal/gears/updates/updates.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,14 +340,21 @@ func (c *UpdatesCollector) ListSnapshots() ([]AptSnapshot, error) {
340340
continue
341341
}
342342

343+
// Validate the base snapshot ID embedded in the filename to prevent
344+
// path traversal if the on-disk directory is ever tampered with.
345+
snapshotID := strings.TrimSuffix(entry.Name(), ".meta")
346+
if err := validateSnapshotID(snapshotID); err != nil {
347+
continue
348+
}
349+
343350
metaFile := fmt.Sprintf("%s/%s", snapshotDir, entry.Name())
344351
data, err := os.ReadFile(metaFile)
345352
if err != nil {
346353
continue
347354
}
348355

349356
snapshot := AptSnapshot{
350-
ID: strings.TrimSuffix(entry.Name(), ".meta"),
357+
ID: snapshotID,
351358
}
352359

353360
// Parse metadata

gearbox/internal/framework/auth/password.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,15 @@ func GenerateRandomPassword() (string, error) {
179179
return password, nil
180180
}
181181

182+
// MinTokenBytes is the minimum number of random bytes for a secure token (256 bits).
183+
const MinTokenBytes = 32
184+
182185
// GenerateSecureToken generates a cryptographically secure random token.
186+
// length is the number of random bytes; it must be at least MinTokenBytes (32).
183187
func GenerateSecureToken(length int) (string, error) {
188+
if length < MinTokenBytes {
189+
length = MinTokenBytes
190+
}
184191
b := make([]byte, length)
185192
if _, err := rand.Read(b); err != nil {
186193
return "", err

gearbox/internal/framework/database/backup.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,15 @@ func validateBackupPath(path string) error {
168168
if strings.Contains(path, "'") {
169169
return fmt.Errorf("path must not contain single quotes")
170170
}
171+
// Disallow SQL comment sequences that could break out of the VACUUM INTO
172+
// string context even without quotes.
173+
if strings.Contains(path, "--") {
174+
return fmt.Errorf("path must not contain double-dash sequences")
175+
}
176+
// Disallow path traversal via consecutive dots.
177+
if strings.Contains(path, "..") {
178+
return fmt.Errorf("path must not contain consecutive dots")
179+
}
171180
if !validBackupPathRe.MatchString(path) {
172181
return fmt.Errorf("path contains invalid characters")
173182
}

gearbox/internal/framework/handler/backup.go

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"os"
88
"path/filepath"
9+
"strings"
910

1011
"github.com/go-chi/chi/v5"
1112
"github.com/sarg3nt/gearbox/internal/framework/database"
@@ -111,7 +112,7 @@ func (h *Handler) APIRestoreBackup(w http.ResponseWriter, r *http.Request) {
111112
}
112113

113114
// Convert to absolute paths for comparison
114-
absBackupDir, err := filepath.Abs(backupDir)
115+
absBackupDir, err := filepath.Abs(filepath.Clean(backupDir))
115116
if err != nil {
116117
w.WriteHeader(http.StatusInternalServerError)
117118
json.NewEncoder(w).Encode(map[string]string{ //#nosec G104
@@ -120,7 +121,7 @@ func (h *Handler) APIRestoreBackup(w http.ResponseWriter, r *http.Request) {
120121
return
121122
}
122123

123-
absBackupPath, err := filepath.Abs(req.BackupPath)
124+
absBackupPath, err := filepath.Abs(filepath.Clean(req.BackupPath))
124125
if err != nil {
125126
w.WriteHeader(http.StatusBadRequest)
126127
json.NewEncoder(w).Encode(map[string]string{ //#nosec G104
@@ -129,17 +130,18 @@ func (h *Handler) APIRestoreBackup(w http.ResponseWriter, r *http.Request) {
129130
return
130131
}
131132

132-
// Ensure the backup file is within the backup directory
133-
if filepath.Dir(absBackupPath) != absBackupDir {
133+
// Ensure the backup file is within the backup directory (HasPrefix guards
134+
// against traversal that lands in a sibling directory with a shared prefix).
135+
if !strings.HasPrefix(absBackupPath, absBackupDir+string(filepath.Separator)) {
134136
w.WriteHeader(http.StatusBadRequest)
135137
json.NewEncoder(w).Encode(map[string]string{ //#nosec G104
136138
"error": "Backup file must be in the backup directory",
137139
})
138140
return
139141
}
140142

141-
// Restore from backup
142-
if err := h.db.RestoreFromBackup(req.BackupPath); err != nil {
143+
// Restore from backup using the validated absolute path
144+
if err := h.db.RestoreFromBackup(absBackupPath); err != nil {
143145
apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("restore backup", err))
144146
return
145147
}
@@ -178,7 +180,7 @@ func (h *Handler) APIDeleteBackup(w http.ResponseWriter, r *http.Request) {
178180
backupDir = "/data/backups"
179181
}
180182

181-
absBackupDir, err := filepath.Abs(backupDir)
183+
absBackupDir, err := filepath.Abs(filepath.Clean(backupDir))
182184
if err != nil {
183185
w.WriteHeader(http.StatusInternalServerError)
184186
json.NewEncoder(w).Encode(map[string]string{ //#nosec G104
@@ -187,7 +189,7 @@ func (h *Handler) APIDeleteBackup(w http.ResponseWriter, r *http.Request) {
187189
return
188190
}
189191

190-
absBackupPath, err := filepath.Abs(backupPath)
192+
absBackupPath, err := filepath.Abs(filepath.Clean(backupPath))
191193
if err != nil {
192194
w.WriteHeader(http.StatusBadRequest)
193195
json.NewEncoder(w).Encode(map[string]string{ //#nosec G104
@@ -196,21 +198,21 @@ func (h *Handler) APIDeleteBackup(w http.ResponseWriter, r *http.Request) {
196198
return
197199
}
198200

199-
if filepath.Dir(absBackupPath) != absBackupDir {
201+
if !strings.HasPrefix(absBackupPath, absBackupDir+string(filepath.Separator)) {
200202
w.WriteHeader(http.StatusBadRequest)
201203
json.NewEncoder(w).Encode(map[string]string{ //#nosec G104
202204
"error": "Backup file must be in the backup directory",
203205
})
204206
return
205207
}
206208

207-
// Delete backup
208-
if err := database.DeleteBackup(backupPath); err != nil {
209+
// Delete backup using the validated absolute path
210+
if err := database.DeleteBackup(absBackupPath); err != nil {
209211
apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("delete backup", err))
210212
return
211213
}
212214

213-
h.logAudit(r, user.ID, "backup_deleted", fmt.Sprintf("Deleted database backup: %s", filepath.Base(backupPath)))
215+
h.logAudit(r, user.ID, "backup_deleted", fmt.Sprintf("Deleted database backup: %s", filepath.Base(absBackupPath)))
214216

215217
w.Header().Set("Content-Type", "application/json")
216218
json.NewEncoder(w).Encode(map[string]interface{}{ //#nosec G104
@@ -241,33 +243,33 @@ func (h *Handler) APIDownloadBackup(w http.ResponseWriter, r *http.Request) {
241243
backupDir = "/data/backups"
242244
}
243245

244-
absBackupDir, err := filepath.Abs(backupDir)
246+
absBackupDir, err := filepath.Abs(filepath.Clean(backupDir))
245247
if err != nil {
246248
http.Error(w, "Failed to resolve backup directory", http.StatusInternalServerError)
247249
return
248250
}
249251

250-
absBackupPath, err := filepath.Abs(backupPath)
252+
absBackupPath, err := filepath.Abs(filepath.Clean(backupPath))
251253
if err != nil {
252254
http.Error(w, "Invalid backup path", http.StatusBadRequest)
253255
return
254256
}
255257

256-
if filepath.Dir(absBackupPath) != absBackupDir {
258+
if !strings.HasPrefix(absBackupPath, absBackupDir+string(filepath.Separator)) {
257259
http.Error(w, "Backup file must be in the backup directory", http.StatusBadRequest)
258260
return
259261
}
260262

261-
// Check if file exists
262-
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
263+
// Check if file exists using the validated absolute path
264+
if _, err := os.Stat(absBackupPath); os.IsNotExist(err) {
263265
http.Error(w, "Backup file not found", http.StatusNotFound)
264266
return
265267
}
266268

267-
h.logAudit(r, user.ID, "backup_downloaded", fmt.Sprintf("Downloaded database backup: %s", filepath.Base(backupPath)))
269+
h.logAudit(r, user.ID, "backup_downloaded", fmt.Sprintf("Downloaded database backup: %s", filepath.Base(absBackupPath)))
268270

269-
// Serve the file
270-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(backupPath)))
271+
// Serve the file; quote the filename to handle spaces and special chars safely
272+
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(absBackupPath)))
271273
w.Header().Set("Content-Type", "application/octet-stream")
272-
http.ServeFile(w, r, backupPath)
274+
http.ServeFile(w, r, absBackupPath)
273275
}

0 commit comments

Comments
 (0)