From fb3f3a3393348a65f90ba3aa3ee04b0bb27d8a0e Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 23 May 2026 10:04:08 -0700 Subject: [PATCH] security: remediate vulnerabilities and issues from codebase audit --- backend/database/database.go | 6 + backend/handlers/archives.go | 18 --- backend/handlers/auth.go | 136 +++++++++++++++-------- backend/handlers/files.go | 76 +++++++------ backend/handlers/sharing.go | 100 +++++++++++------ backend/handlers/tus.go | 35 ++++-- backend/handlers/utils.go | 24 ++++ backend/handlers/webdav.go | 57 +++++++--- backend/main.go | 30 ++++- backend/middleware/auth.go | 50 ++++++++- backend/middleware/bruteforce.go | 5 +- backend/workers/tasks.go | 6 +- frontend/src/components/FileExplorer.tsx | 75 +++++++++---- frontend/src/components/FilePreview.tsx | 8 +- frontend/src/lib/api.ts | 47 +++++--- 15 files changed, 465 insertions(+), 208 deletions(-) diff --git a/backend/database/database.go b/backend/database/database.go index 57beb4c..c623901 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -182,3 +182,9 @@ func (db *DB) AddAuditLog(action, details, ip string) error { _, err := db.Exec("INSERT INTO audit_log (action, details, ip_address) VALUES (?, ?, ?)", action, details, ip) return err } + +// CleanOldAuditLogs deletes audit logs older than 30 days. +func (db *DB) CleanOldAuditLogs() error { + _, err := db.Exec("DELETE FROM audit_log WHERE timestamp < datetime('now', '-30 days')") + return err +} diff --git a/backend/handlers/archives.go b/backend/handlers/archives.go index 29d7abc..3c69856 100644 --- a/backend/handlers/archives.go +++ b/backend/handlers/archives.go @@ -130,22 +130,4 @@ func (h *ArchiveHandler) syncArchiveToDB(relPath string) { `, cleanPath, filepath.Base(cleanPath), info.Size(), info.IsDir()) } -func resolveSafe(root, rel string) (string, error) { - cleaned := filepath.Clean(rel) - if strings.Contains(cleaned, "..") { - return "", fmt.Errorf("path traversal detected") - } - full := filepath.Join(root, cleaned) - abs, err := filepath.Abs(full) - if err != nil { - return "", fmt.Errorf("invalid path") - } - - absRoot, _ := filepath.Abs(root) - if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) { - return "", fmt.Errorf("access denied") - } - - return abs, nil -} diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 6b236a3..917b8ea 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -4,6 +4,9 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "strings" + "crypto/subtle" + "sync" "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/database" @@ -17,6 +20,18 @@ type AuthHandler struct { DB *database.DB Config *config.Config Guard *middleware.BruteForceGuard + + tempTOTP map[string]string + totpMu sync.Mutex +} + +func NewAuthHandler(db *database.DB, cfg *config.Config, guard *middleware.BruteForceGuard) *AuthHandler { + return &AuthHandler{ + DB: db, + Config: cfg, + Guard: guard, + tempTOTP: make(map[string]string), + } } type SetupRequest struct { @@ -173,6 +188,26 @@ func (h *AuthHandler) Refresh(c *fiber.Ctx) error { }) } +// GetDownloadToken issues a short-lived token for file downloads +// GET /api/auth/download-token +func (h *AuthHandler) GetDownloadToken(c *fiber.Ctx) error { + userID := c.Locals("userID") + username := c.Locals("username") + + token, err := middleware.GenerateDownloadToken( + int(userID.(float64)), + username.(string), + h.Config.JWTSecret, + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate download token"}) + } + + return c.JSON(fiber.Map{ + "token": token, + }) +} + // Enable2FA generates a TOTP secret and returns the provisioning URI. // POST /api/auth/2fa/enable func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error { @@ -186,11 +221,10 @@ func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate TOTP secret"}) } - // Store the secret but don't enable 2FA until confirmed - _, err = h.DB.Exec("UPDATE users SET totp_secret = ? WHERE username = ?", key.Secret(), username) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save TOTP secret"}) - } + // Store the secret temporarily in memory + h.totpMu.Lock() + h.tempTOTP[username] = key.Secret() + h.totpMu.Unlock() return c.JSON(fiber.Map{ "secret": key.Secret(), @@ -210,21 +244,27 @@ func (h *AuthHandler) Confirm2FA(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) } - var secret string - err := h.DB.QueryRow("SELECT totp_secret FROM users WHERE username = ?", username).Scan(&secret) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + h.totpMu.Lock() + secret, exists := h.tempTOTP[username] + h.totpMu.Unlock() + + if !exists { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no pending 2FA setup found"}) } if !totp.Validate(body.Code, secret) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"}) } - _, err = h.DB.Exec("UPDATE users SET totp_enabled = 1 WHERE username = ?", username) + _, err := h.DB.Exec("UPDATE users SET totp_enabled = 1, totp_secret = ? WHERE username = ?", secret, username) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to enable 2FA"}) } + h.totpMu.Lock() + delete(h.tempTOTP, username) + h.totpMu.Unlock() + h.DB.AddAuditLog("2fa_enabled", fmt.Sprintf("User '%s' enabled 2FA", username), c.IP()) return c.JSON(fiber.Map{"message": "2FA enabled successfully"}) @@ -235,7 +275,25 @@ func (h *AuthHandler) Confirm2FA(c *fiber.Ctx) error { func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error { username := c.Locals("username").(string) - _, err := h.DB.Exec("UPDATE users SET totp_enabled = 0, totp_secret = '' WHERE username = ?", username) + var body struct { + Code string `json:"code"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + var secret string + err := h.DB.QueryRow("SELECT totp_secret FROM users WHERE username = ?", username).Scan(&secret) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if !totp.Validate(body.Code, secret) { + h.Guard.RecordFailure(c.IP()) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"}) + } + + _, err = h.DB.Exec("UPDATE users SET totp_enabled = 0, totp_secret = '' WHERE username = ?", username) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to disable 2FA"}) } @@ -294,7 +352,8 @@ func hashPassword(password string) (string, error) { } hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) - encoded := fmt.Sprintf("%s$%s", + // Output PHC format + encoded := fmt.Sprintf("$argon2id$v=19$m=65536,t=2,p=4$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash), ) @@ -302,7 +361,23 @@ func hashPassword(password string) (string, error) { } func verifyPassword(password, encoded string) bool { - parts := splitN(encoded, "$", 2) + if strings.HasPrefix(encoded, "$argon2id$") { + // PHC Format + parts := strings.Split(encoded, "$") + if len(parts) != 6 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { return false } + expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { return false } + + hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) + return subtle.ConstantTimeCompare(hash, expectedHash) == 1 + } + + // Legacy format: base64(salt)$base64(hash) + parts := strings.SplitN(encoded, "$", 2) if len(parts) != 2 { return false } @@ -318,38 +393,5 @@ func verifyPassword(password, encoded string) bool { } hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) - - if len(hash) != len(expectedHash) { - return false - } - - // Constant-time comparison to prevent timing attacks - var diff byte - for i := range hash { - diff |= hash[i] ^ expectedHash[i] - } - return diff == 0 -} - -func splitN(s, sep string, n int) []string { - result := make([]string, 0, n) - for i := 0; i < n-1; i++ { - idx := indexOf(s, sep) - if idx < 0 { - break - } - result = append(result, s[:idx]) - s = s[idx+len(sep):] - } - result = append(result, s) - return result -} - -func indexOf(s, sub string) int { - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return i - } - } - return -1 + return subtle.ConstantTimeCompare(hash, expectedHash) == 1 } diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 2d0bdf0..ffa0c3f 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -293,7 +293,7 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error { // For files, attempt to get media metadata cat := categorizeFile(filepath.Ext(info.Name())) if cat == "images" || cat == "videos" || cat == "audio" { - cmd := exec.Command("exiftool", "-json", resolvedPath) + cmd := exec.Command("exiftool", "-json", "--", resolvedPath) output, err := cmd.Output() if err == nil { var meta []map[string]interface{} @@ -327,7 +327,7 @@ func (h *FSHandler) CreateFolder(c *fiber.Ctx) error { parentDir := filepath.Dir(body.Path) folderName := filepath.Base(body.Path) - parentFullPath, err := h.resolveSafe(parentDir) + parentFullPath, err := resolveSafe(h.Config.StorageDir, parentDir) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -371,7 +371,7 @@ func (h *FSHandler) Rename(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } - oldFull, err := h.resolveSafe(body.OldPath) + oldFull, err := resolveSafe(h.Config.StorageDir, body.OldPath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -380,7 +380,7 @@ func (h *FSHandler) Rename(c *fiber.Ctx) error { uniqueName := GetUniquePath(parentDir, body.NewName) newPath := filepath.Join(filepath.Dir(body.OldPath), uniqueName) - newFull, err := h.resolveSafe(newPath) + newFull, err := resolveSafe(h.Config.StorageDir, newPath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -414,12 +414,12 @@ func (h *FSHandler) Move(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } - srcFull, err := h.resolveSafe(body.SourcePath) + srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } - dstFull, err := h.resolveSafe(body.DestPath) + dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -468,12 +468,12 @@ func (h *FSHandler) Copy(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } - srcFull, err := h.resolveSafe(body.SourcePath) + srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } - dstFull, err := h.resolveSafe(body.DestPath) + dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -643,7 +643,7 @@ func (h *FSHandler) DownloadFolder(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"}) } - fullPath, err := h.resolveSafe(folderPath) + fullPath, err := resolveSafe(h.Config.StorageDir, folderPath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -722,7 +722,7 @@ func (h *FSHandler) DownloadBulk(c *fiber.Ctx) error { defer zipWriter.Close() for _, p := range body.Paths { - fullPath, err := h.resolveSafe(p) + fullPath, err := resolveSafe(h.Config.StorageDir, p) if err != nil { continue } @@ -851,8 +851,26 @@ func (h *FSHandler) Search(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"}) } - // Append wildcard for prefix matching - ftsQuery := query + "*" + // Sanitize FTS5 query to prevent syntax errors + words := strings.Fields(query) + var safeWords []string + for _, w := range words { + // Keep only alphanumeric characters + var clean strings.Builder + for _, r := range w { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + clean.WriteRune(r) + } + } + if clean.Len() > 0 { + safeWords = append(safeWords, clean.String()+"*") + } + } + + ftsQuery := strings.Join(safeWords, " AND ") + if ftsQuery == "" { + return c.JSON(fiber.Map{"items": []FileInfo{}, "count": 0}) + } rows, err := h.DB.Query(` SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned @@ -918,7 +936,7 @@ func (h *FSHandler) Upload(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"}) } - fullDest, err := h.resolveSafe(destPath) + fullDest, err := resolveSafe(h.Config.StorageDir, destPath) if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -1034,26 +1052,6 @@ func (h *FSHandler) StorageDashboard(c *fiber.Ctx) error { // --- Helper functions --- -func (h *FSHandler) resolveSafe(relativePath string) (string, error) { - cleaned := filepath.Clean(relativePath) - if strings.Contains(cleaned, "..") { - return "", fmt.Errorf("path traversal detected") - } - - full := filepath.Join(h.Config.StorageDir, cleaned) - abs, err := filepath.Abs(full) - if err != nil { - return "", fmt.Errorf("invalid path") - } - - absRoot, _ := filepath.Abs(h.Config.StorageDir) - if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) { - return "", fmt.Errorf("access denied") - } - - return abs, nil -} - func generateChecksum(filePath string) (string, error) { f, err := os.Open(filePath) if err != nil { @@ -1132,6 +1130,18 @@ func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing checksum"}) } + // Sanitize checksum: should only contain hex characters to prevent path traversal + isHex := true + for _, char := range checksum { + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) { + isHex = false + break + } + } + if !isHex { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid checksum format"}) + } + thumbPath := filepath.Join(h.Config.ThumbnailDir, checksum+".jpg") if _, err := os.Stat(thumbPath); os.IsNotExist(err) { diff --git a/backend/handlers/sharing.go b/backend/handlers/sharing.go index e872f9d..2e84cf3 100644 --- a/backend/handlers/sharing.go +++ b/backend/handlers/sharing.go @@ -7,14 +7,13 @@ import ( "mime" "os" "path/filepath" - "strings" "time" "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/database" + "git.elijahkuntz.com/Elijah/drive/middleware" "github.com/gofiber/fiber/v2" "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" ) type ShareHandler struct { @@ -37,13 +36,20 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) } - // Validate path - fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path)) + // Validate path using resolveSafe + fullPath, err := resolveSafe(h.Config.StorageDir, req.Path) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"}) + } + info, err := os.Stat(fullPath) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"}) } + // Clean path for DB + cleanPath := filepath.ToSlash(filepath.Clean(req.Path)) + isDirInt := 0 if info.IsDir() { isDirInt = 1 @@ -52,10 +58,9 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { id := uuid.New().String() var hash *string if req.Password != "" { - hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) + encoded, err := hashPassword(req.Password) if err == nil { - s := string(hashed) - hash = &s + hash = &encoded } } @@ -73,7 +78,7 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { _, err = h.DB.Exec(` INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count) VALUES (?, ?, ?, ?, ?, ?, 0) - `, id, req.Path, isDirInt, hash, expiresAt, maxDownloads) + `, id, cleanPath, isDirInt, hash, expiresAt, maxDownloads) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"}) @@ -191,7 +196,7 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { if req.Password == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true}) } - if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(req.Password)); err != nil { + if !verifyPassword(req.Password, *hash) { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) } } @@ -199,14 +204,14 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { // Send file info targetRelPath := filepath.Clean(filepath.FromSlash(path)) if subPath := c.Query("path"); subPath != "" { - cleanSub := filepath.Clean(filepath.FromSlash(subPath)) - if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"}) - } - targetRelPath = filepath.Join(targetRelPath, cleanSub) + targetRelPath = filepath.Join(targetRelPath, filepath.Clean(filepath.FromSlash(subPath))) + } + + fullPath, err := resolveSafe(h.Config.StorageDir, targetRelPath) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"}) } - fullPath := filepath.Join(h.Config.StorageDir, targetRelPath) info, err := os.Stat(fullPath) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"}) @@ -251,22 +256,61 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { return c.JSON(response) } +// CreateShareToken generates a short-lived HMAC token for public share downloads +// POST /api/public/share/:id/token +func (h *ShareHandler) CreateShareToken(c *fiber.Ctx) error { + id := c.Params("id") + var req struct { + Password string `json:"password"` + } + c.BodyParser(&req) + + var hash *string + err := h.DB.QueryRow(`SELECT password_hash FROM share_links WHERE token = ?`, id).Scan(&hash) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "share not found"}) + } + + if hash != nil { + if req.Password == "" || !verifyPassword(req.Password, *hash) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) + } + } + + // Generate a short-lived download token with the share ID + token, err := middleware.GenerateDownloadToken(0, "public_share_"+id, h.Config.JWTSecret) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"}) + } + + return c.JSON(fiber.Map{"token": token}) +} + // DownloadPublicShare serves the file. // GET /api/public/download/:id func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { id := c.Params("id") - pwd := c.Query("pwd") + + // Instead of ?pwd=..., we require the ?token=... + tokenString := c.Query("token") + if tokenString == "" { + return c.Status(fiber.StatusUnauthorized).SendString("missing download token") + } + + // Validate the JWT download token + if err := middleware.ValidateDownloadToken(tokenString, h.Config.JWTSecret, "public_share_"+id); err != nil { + return c.Status(fiber.StatusUnauthorized).SendString("invalid or expired download token") + } var path string - var hash *string var expiresAt *time.Time var maxDownloads *int var downloads int err := h.DB.QueryRow(` - SELECT target_path, password_hash, expires_at, max_downloads, download_count + SELECT target_path, expires_at, max_downloads, download_count FROM share_links WHERE token = ? - `, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads) + `, id).Scan(&path, &expiresAt, &maxDownloads, &downloads) if err != nil { return c.Status(fiber.StatusNotFound).SendString("not found") @@ -280,22 +324,16 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { return c.Status(fiber.StatusForbidden).SendString("limit reached") } - if hash != nil { - if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(pwd)); err != nil { - return c.Status(fiber.StatusUnauthorized).SendString("unauthorized") - } - } - targetRelPath := filepath.Clean(filepath.FromSlash(path)) if subPath := c.Query("path"); subPath != "" { - cleanSub := filepath.Clean(filepath.FromSlash(subPath)) - if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") { - return c.Status(fiber.StatusForbidden).SendString("invalid path") - } - targetRelPath = filepath.Join(targetRelPath, cleanSub) + targetRelPath = filepath.Join(targetRelPath, filepath.Clean(filepath.FromSlash(subPath))) + } + + fullPath, err := resolveSafe(h.Config.StorageDir, targetRelPath) + if err != nil { + return c.Status(fiber.StatusForbidden).SendString("invalid path") } - fullPath := filepath.Join(h.Config.StorageDir, targetRelPath) info, err := os.Stat(fullPath) if err != nil { return c.Status(fiber.StatusNotFound).SendString("not found") diff --git a/backend/handlers/tus.go b/backend/handlers/tus.go index 4ef855d..64aa88a 100644 --- a/backend/handlers/tus.go +++ b/backend/handlers/tus.go @@ -84,7 +84,11 @@ func (h *TusHandler) Create(c *fiber.Ctx) error { } // Resolve destination directory and ensure unique filename - destFull := filepath.Join(h.Config.StorageDir, filepath.FromSlash(destPath)) + destFull, err := resolveSafe(h.Config.StorageDir, destPath) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid destination path"}) + } + uniqueFileName := GetUniquePath(destFull, fileName) // Generate unique upload ID @@ -184,17 +188,28 @@ func (h *TusHandler) Patch(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"}) } - body := c.Body() - n, err := f.Write(body) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"}) + reader := c.Context().RequestBodyStream() + if reader == nil { + // Fallback for smaller payloads or if streaming isn't fully active + body := c.Body() + n, err := f.Write(body) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"}) + } + + h.mu.Lock() + upload.Offset += int64(n) + h.mu.Unlock() + } else { + written, err := io.Copy(f, reader) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "stream read/write failed"}) + } + h.mu.Lock() + upload.Offset += written + h.mu.Unlock() } - // Update offset - h.mu.Lock() - upload.Offset += int64(n) - h.mu.Unlock() - c.Set("Tus-Resumable", "1.0.0") c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10)) diff --git a/backend/handlers/utils.go b/backend/handlers/utils.go index 3432bbb..d692779 100644 --- a/backend/handlers/utils.go +++ b/backend/handlers/utils.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "strings" ) var numberSuffixRegex = regexp.MustCompile(` \(\d+\)$`) @@ -31,3 +32,26 @@ func GetUniquePath(baseDir, name string) string { } } } + +// resolveSafe safely resolves a requested path against a root directory, +// ensuring the result is completely contained within the root. +// Returns the absolute clean path or an error if traversal is detected. +func resolveSafe(rootDir, reqPath string) (string, error) { + cleanRoot := filepath.Clean(rootDir) + + // Pre-check for typical traversal patterns to be safe + cleanReq := filepath.Clean(filepath.FromSlash(reqPath)) + if strings.HasPrefix(cleanReq, "..") || strings.HasPrefix(cleanReq, "/") || strings.HasPrefix(cleanReq, "\\") { + return "", fmt.Errorf("invalid path: contains traversal patterns") + } + + absPath := filepath.Join(cleanRoot, cleanReq) + + // Ensure the resulting path starts with the root directory + separator + // (or is exactly the root directory) + if !strings.HasPrefix(absPath, cleanRoot+string(filepath.Separator)) && absPath != cleanRoot { + return "", fmt.Errorf("path traversal attempt detected") + } + + return absPath, nil +} diff --git a/backend/handlers/webdav.go b/backend/handlers/webdav.go index ac19ff1..3eb109d 100644 --- a/backend/handlers/webdav.go +++ b/backend/handlers/webdav.go @@ -3,10 +3,13 @@ package handlers import ( "encoding/base64" "fmt" + "mime" "net/http" "os" "path/filepath" "strings" + "sync" + "time" "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/database" @@ -19,6 +22,9 @@ type WebDAVHandler struct { Handler *webdav.Handler DB *database.DB Config *config.Config + + authCache map[string]time.Time + cacheMu sync.RWMutex } func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler { @@ -39,9 +45,10 @@ func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler { } return &WebDAVHandler{ - Handler: h, - DB: db, - Config: cfg, + Handler: h, + DB: db, + Config: cfg, + authCache: make(map[string]time.Time), } } @@ -63,19 +70,33 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error { } if hasBasicAuth { - // Verify basic auth against DB - var storedHash string - err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash) - if err != nil { - c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`) - return c.SendStatus(fiber.StatusUnauthorized) - } + cacheKey := user + ":" + pass - // In a real implementation we would compare the Argon2 hash here. - // For simplicity, we assume the frontend uses an app password or we verify. - // Note: Argon2 requires full verification which is slow on every WebDAV request. - // It's recommended to implement an App Password system for WebDAV. - _ = pass + h.cacheMu.RLock() + expireTime, ok := h.authCache[cacheKey] + h.cacheMu.RUnlock() + + if !ok || time.Now().After(expireTime) { + // Verify basic auth against DB + var storedHash string + err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash) + if err != nil || !verifyPassword(pass, storedHash) { + c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`) + return c.SendStatus(fiber.StatusUnauthorized) + } + + // Cache successful auth for 5 minutes + h.cacheMu.Lock() + h.authCache[cacheKey] = time.Now().Add(5 * time.Minute) + + // Simple cache cleanup + for k, v := range h.authCache { + if time.Now().After(v) { + delete(h.authCache, k) + } + } + h.cacheMu.Unlock() + } } else if c.Locals("userID") == nil { c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`) return c.SendStatus(fiber.StatusUnauthorized) @@ -107,11 +128,11 @@ func (h *WebDAVHandler) syncToDB(relPath string) { checksum, _ := generateChecksum(fullPath) // Reusing the helper from files.go cleanPath := filepath.ToSlash(relPath) - // Default mime type mimeType := "application/octet-stream" if ext := filepath.Ext(cleanPath); ext != "" { - // Go's built-in mime types - // Note: mime.TypeByExtension is handled by the DB trigger or just simple mapping + if m := mime.TypeByExtension(ext); m != "" { + mimeType = m + } } h.DB.Exec(` diff --git a/backend/main.go b/backend/main.go index 7316c18..bc9b1f1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -75,6 +75,15 @@ func main() { workers.StartBackupWorker(cfg, db) workers.StartTrashWorker(cfg, db) + // Start prune worker + go func() { + ticker := time.NewTicker(12 * time.Hour) + for range ticker.C { + workers.Tasks.CleanOldTasks() + db.CleanOldAuditLogs() + } + }() + // Create Fiber app app := fiber.New(fiber.Config{ BodyLimit: 100 * 1024 * 1024 * 1024, // 100 GB (for chunked uploads, each chunk is smaller) @@ -94,8 +103,13 @@ func main() { return c.Next() }) app.Use(helmet.New()) + allowedOrigins := os.Getenv("ALLOWED_ORIGINS") + if allowedOrigins == "" { + allowedOrigins = "http://localhost:3000" + } + app.Use(cors.New(cors.Config{ - AllowOrigins: "*", + AllowOrigins: allowedOrigins, AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable", AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD", ExposeHeaders: "Location, Upload-Offset, Upload-Length, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size", @@ -116,6 +130,7 @@ func main() { Max: 20, Expiration: 1 * time.Minute, })) + public.Post("/share/:id/token", shareHandler.CreateShareToken) public.Post("/share/:id", shareHandler.GetPublicShare) public.Get("/download/:id", shareHandler.DownloadPublicShare) @@ -125,6 +140,12 @@ func main() { // === PROTECTED ROUTES (require JWT) === protected := app.Group("/api", middleware.AuthMiddleware(cfg.JWTSecret)) + + // Apply rate limiting to all authenticated endpoints (e.g. 100 req/min) + protected.Use(limiter.New(limiter.Config{ + Max: 100, + Expiration: 1 * time.Minute, + })) // Token refresh protected.Post("/auth/refresh", authHandler.Refresh) @@ -137,6 +158,9 @@ func main() { protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA) protected.Post("/auth/2fa/disable", authHandler.Disable2FA) + // Download tokens + protected.Get("/auth/download-token", authHandler.GetDownloadToken) + // Settings & Audit protected.Get("/settings", settingsHandler.GetSettings) protected.Put("/settings", settingsHandler.UpdateSettings) @@ -162,8 +186,6 @@ func main() { protected.Post("/files/rename", fsHandler.Rename) protected.Post("/files/move", fsHandler.Move) protected.Post("/files/copy", fsHandler.Copy) - protected.Get("/files/download-folder", fsHandler.DownloadFolder) - protected.Post("/files/download-bulk", fsHandler.DownloadBulk) protected.Post("/files/upload", fsHandler.Upload) protected.Get("/files/folders-tree", fsHandler.GetFolderTree) @@ -172,6 +194,8 @@ func main() { files := protected.Group("/files", middleware.PathJail(cfg.StorageDir)) files.Get("/info/*", fsHandler.GetExtendedFileInfo) files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail) + files.Get("/download-folder", fsHandler.DownloadFolder) + files.Post("/download-bulk", fsHandler.DownloadBulk) files.Get("/download/*", fsHandler.Download) files.Post("/zip", archiveHandler.Zip) files.Post("/unzip", archiveHandler.Unzip) diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 70d2c48..777dea4 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -21,9 +21,10 @@ func AuthMiddleware(jwtSecret string) fiber.Handler { } } - // Fallback to query parameter for img/video tags + isQueryToken := false if tokenString == "" { tokenString = c.Query("token") + isQueryToken = true } if tokenString == "" { @@ -52,6 +53,17 @@ func AuthMiddleware(jwtSecret string) fiber.Handler { }) } + tokenType, _ := claims["type"].(string) + if isQueryToken { + if tokenType != "download" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token type for query parameter"}) + } + } else { + if tokenType != "access" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token type for authorization header"}) + } + } + c.Locals("userID", claims["sub"]) c.Locals("username", claims["username"]) @@ -86,3 +98,39 @@ func GenerateRefreshToken(userID int, username, secret string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(secret)) } + +// GenerateDownloadToken creates a short-lived download token. +func GenerateDownloadToken(userID int, username, secret string) (string, error) { + claims := jwt.MapClaims{ + "sub": userID, + "username": username, + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + "type": "download", + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +// ValidateDownloadToken validates a download token +func ValidateDownloadToken(tokenString, secret, expectedUsername string) error { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + + if err != nil || !token.Valid { + return fiber.NewError(fiber.StatusUnauthorized, "invalid token") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || claims["type"] != "download" { + return fiber.NewError(fiber.StatusUnauthorized, "invalid token claims") + } + + if expectedUsername != "" && claims["username"] != expectedUsername { + return fiber.NewError(fiber.StatusUnauthorized, "token belongs to different context") + } + + return nil +} diff --git a/backend/middleware/bruteforce.go b/backend/middleware/bruteforce.go index b84b78b..ab163e8 100644 --- a/backend/middleware/bruteforce.go +++ b/backend/middleware/bruteforce.go @@ -46,22 +46,21 @@ func (g *BruteForceGuard) Check() fiber.Handler { g.mu.Lock() attempt, exists := g.attempts[ip] - g.mu.Unlock() if exists && attempt.Count >= g.maxAttempts { lockoutDuration := time.Duration(g.lockoutSecs) * time.Second if time.Since(attempt.LockedAt) < lockoutDuration { remaining := lockoutDuration - time.Since(attempt.LockedAt) + g.mu.Unlock() return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ "error": "too many login attempts, account temporarily locked", "retry_after": int(remaining.Seconds()), }) } // Lockout expired, reset - g.mu.Lock() delete(g.attempts, ip) - g.mu.Unlock() } + g.mu.Unlock() return c.Next() } diff --git a/backend/workers/tasks.go b/backend/workers/tasks.go index a5c20d8..be3ef10 100644 --- a/backend/workers/tasks.go +++ b/backend/workers/tasks.go @@ -208,13 +208,11 @@ func UnzipAsync(taskID string, sourceFull, destFolder string) { for i, f := range r.File { // Prevent Zip Slip vulnerability - cleanPath := filepath.Clean(f.Name) - if strings.HasPrefix(cleanPath, "..") { + fpath := filepath.Join(destFolder, f.Name) + if !strings.HasPrefix(fpath, filepath.Clean(destFolder)+string(os.PathSeparator)) { continue // Skip malicious paths } - fpath := filepath.Join(destFolder, cleanPath) - if f.FileInfo().IsDir() { os.MkdirAll(fpath, os.ModePerm) continue diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 1ce7d8d..731ba46 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -159,7 +159,7 @@ function Tooltip({ children, text }: { children: React.ReactNode; text: string } ); } -function ThumbnailImage({ checksum, fallbackIcon }: { checksum: string; fallbackIcon: React.ReactNode }) { +function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: { checksum: string; fallbackIcon: React.ReactNode; downloadToken: string }) { const [errorCount, setErrorCount] = useState(0); const [key, setKey] = useState(0); const [showFallback, setShowFallback] = useState(false); @@ -168,7 +168,7 @@ function ThumbnailImage({ checksum, fallbackIcon }: { checksum: string; fallback <> 0 ? `?t=${key}` : ''}`} + src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 0 ? `&t=${key}` : ''}` : ''} alt="" className="w-full h-full object-cover" style={{ display: showFallback ? 'none' : 'block' }} @@ -248,6 +248,26 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const [selectionBox, setSelectionBox] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null); const [initialSelectedFiles, setInitialSelectedFiles] = useState>(new Set()); const searchTimeoutRef = useRef(undefined); + const [downloadToken, setDownloadToken] = useState(''); + + // Fetch download token loop + useEffect(() => { + let mounted = true; + const fetchToken = async () => { + try { + const token = await api.createDownloadToken(); + if (mounted) setDownloadToken(token); + } catch (e) { + console.error('Failed to refresh download token', e); + } + }; + fetchToken(); + const interval = setInterval(fetchToken, 4 * 60 * 1000); // 4 minutes + return () => { + mounted = false; + clearInterval(interval); + }; + }, []); const mainContainerRef = useRef(null); const fileInputRef = useRef(null); @@ -810,7 +830,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { if (isMedia && file.checksum && viewMode === 'grid') { return (
- +
); } @@ -1496,18 +1516,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { style={{ color: 'var(--color-text-primary)' }} title="Download Selected" onClick={() => { + if (!downloadToken) return; const form = document.createElement('form'); form.method = 'POST'; - form.action = api.getBulkDownloadUrl(); + form.action = api.getBulkDownloadUrl(downloadToken); form.target = '_blank'; - Array.from(selectedFiles).forEach(path => { - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = 'paths'; - input.value = path; - form.appendChild(input); - }); + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'paths'; + input.value = JSON.stringify(Array.from(selectedFiles)); + form.appendChild(input); document.body.appendChild(form); form.submit(); @@ -1598,17 +1617,27 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { setContextMenu(null); }} /> - } - label="Download" - onClick={() => { - const downloadUrl = contextMenu.file.is_dir - ? api.getDownloadFolderUrl(contextMenu.file.path) - : api.getDownloadUrl(contextMenu.file.path); - window.open(downloadUrl, '_blank'); - setContextMenu(null); - }} - /> + {contextMenu.file.is_dir ? ( + } + label="Download Folder" + onClick={() => { + if (!downloadToken) return; + window.location.href = api.getDownloadFolderUrl(contextMenu.file.path, downloadToken); + setContextMenu(null); + }} + /> + ) : ( + } + label="Download" + onClick={() => { + if (!downloadToken) return; + window.location.href = api.getDownloadUrl(contextMenu.file.path, downloadToken); + setContextMenu(null); + }} + /> + )} } label="Rename" @@ -1767,7 +1796,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { - setPreviewFile(null)} /> + setPreviewFile(null)} downloadToken={downloadToken} /> {sharingFile && ( setSharingFile(null)} /> diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index f1447aa..1a704d8 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -18,6 +18,7 @@ interface FilePreviewProps { mime_type: string; } | null; onClose: () => void; + downloadToken?: string; } function getFileDisplayName(name: string) { @@ -26,7 +27,7 @@ function getFileDisplayName(name: string) { return name; } -export default function FilePreview({ file, onClose }: FilePreviewProps) { +export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) { const [content, setContent] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -53,7 +54,8 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) { setLoading(true); setError(null); - fetch(api.getDownloadUrl(file.path)) + if (!downloadToken) return; + fetch(api.getDownloadUrl(file.path, downloadToken)) .then((res) => { if (!res.ok) throw new Error('Failed to load file content'); return res.text(); @@ -67,7 +69,7 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) { if (!file) return null; const previewType = getPreviewType(file); - const downloadUrl = api.getDownloadUrl(file.path); + const downloadUrl = downloadToken ? api.getDownloadUrl(file.path, downloadToken) : ''; return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d4c1d1c..ee852b1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -227,24 +227,32 @@ class ApiClient { }); } - getDownloadUrl(path: string) { - const url = `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}`; - return this.accessToken ? `${url}&token=${this.accessToken}` : url; + async createDownloadToken(): Promise { + const res = await fetch(`${API_BASE}/api/auth/download-token`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + if (!res.ok) throw new Error('Failed to fetch download token'); + const data = await res.json(); + return data.token; } - getDownloadFolderUrl(path: string) { - const url = `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}`; - return this.accessToken ? `${url}&token=${this.accessToken}` : url; + getDownloadUrl(path: string, token: string) { + return `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}&token=${token}`; } - getBulkDownloadUrl() { - const url = `${API_BASE}/api/files/download-bulk`; - return this.accessToken ? `${url}?token=${this.accessToken}` : url; + getDownloadFolderUrl(path: string, token: string) { + return `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}&token=${token}`; } - getThumbnailUrl(checksum: string) { - const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`; - return this.accessToken ? `${url}?token=${this.accessToken}` : url; + getBulkDownloadUrl(token: string) { + return `${API_BASE}/api/files/download-bulk?token=${token}`; + } + + getThumbnailUrl(checksum: string, token: string) { + return `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}?token=${token}`; } getStreamHeaders(): Record { @@ -379,10 +387,21 @@ class ApiClient { return res.json(); } - getPublicDownloadUrl(id: string, password?: string, path?: string) { + async createPublicShareToken(id: string, password?: string) { + const res = await fetch(`${API_BASE}/api/public/share/${id}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: password || '' }), + }); + if (!res.ok) throw await res.json(); + const data = await res.json(); + return data.token; + } + + getPublicDownloadUrl(id: string, token: string, path?: string) { let url = `${API_BASE}/api/public/download/${id}`; const params = new URLSearchParams(); - if (password) params.set('pwd', password); + if (token) params.set('token', token); if (path) params.set('path', path); const qs = params.toString(); if (qs) {