security: remediate vulnerabilities and issues from codebase audit
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s

This commit is contained in:
Elijah 2026-05-23 10:04:08 -07:00
parent b60a09196a
commit fb3f3a3393
15 changed files with 465 additions and 208 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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")

View file

@ -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))

View file

@ -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
}

View file

@ -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(`

View file

@ -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)

View file

@ -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
}

View file

@ -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()
}

View file

@ -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