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

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