diff --git a/backend/config/config.go b/backend/config/config.go index 313d529..e447192 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -16,9 +16,11 @@ type Config struct { TrashDir string VersionsDir string BackupDir string - MaxLoginAttempts int LockoutSeconds int OnlyOfficeJWT string + OnlyOfficeHost string + PublicURL string + InternalAPIURL string } func Load() *Config { @@ -31,6 +33,13 @@ func Load() *Config { MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5), LockoutSeconds: getEnvInt("LOCKOUT_SECONDS", 300), // 5 minutes OnlyOfficeJWT: getEnv("ONLYOFFICE_JWT_SECRET", ""), + OnlyOfficeHost: getEnv("ONLYOFFICE_HOST", "office.elijahkuntz.com"), + PublicURL: getEnv("PUBLIC_URL", ""), + InternalAPIURL: getEnv("INTERNAL_API_URL", "http://192.168.50.81:5827/api"), + } + + if cfg.InternalAPIURL == "" && cfg.PublicURL != "" { + cfg.InternalAPIURL = cfg.PublicURL } cfg.DBPath = cfg.DataDir + "/drive.db" diff --git a/backend/database/database.go b/backend/database/database.go index 860f907..8170b92 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -183,6 +183,6 @@ func (db *DB) AddAuditLog(action, details, ip string) error { // 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')") + _, err := db.Exec("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')") return err } diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 25fec26..0bc7fed 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -199,6 +199,7 @@ func (h *AuthHandler) GetDownloadToken(c *fiber.Ctx) error { 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"}) diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 2ff5174..ab37eac 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -3,6 +3,7 @@ package handlers import ( "archive/zip" "bufio" + "context" "database/sql" "encoding/json" "fmt" @@ -293,12 +294,22 @@ 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) - output, err := cmd.Output() - if err == nil { - var meta []map[string]interface{} - if err := json.Unmarshal(output, &meta); err == nil && len(meta) > 0 { - fi.Metadata = meta[0] + ext := strings.ToLower(filepath.Ext(info.Name())) + validExts := map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".mp4": true, ".mov": true, ".webm": true, ".mkv": true, + ".mp3": true, ".wav": true, ".flac": true, ".m4a": true, + } + if validExts[ext] { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "exiftool", "-json", "--", resolvedPath) + output, err := cmd.Output() + if err == nil { + var meta []map[string]interface{} + if err := json.Unmarshal(output, &meta); err == nil && len(meta) > 0 { + fi.Metadata = meta[0] + } } } } @@ -548,9 +559,9 @@ func (h *FSHandler) Delete(c *fiber.Ctx) error { } // Remove both potential locations to be safe - os.RemoveAll(resolvedPath) + safeRemovePath(h.Config.StorageDir, resolvedPath) if relativePath != "." { - os.RemoveAll(trashPath) + safeRemovePath(h.Config.TrashDir, trashPath) } cleanPath := filepath.ToSlash(relativePath) @@ -584,7 +595,7 @@ func (h *FSHandler) Delete(c *fiber.Ctx) error { if err := copyFile(resolvedPath, trashPath); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"}) } - os.RemoveAll(resolvedPath) + safeRemovePath(h.Config.StorageDir, resolvedPath) } // Update DB @@ -691,7 +702,7 @@ func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error { if err := copyFile(trashPath, destFull); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to restore file"}) } - os.RemoveAll(trashPath) + safeRemovePath(h.Config.TrashDir, trashPath) } h.DB.Exec("UPDATE files SET path = ?, name = ?, is_trashed = 0, trashed_at = NULL WHERE path = ?", originalPath, filepath.Base(originalPath), body.Path) @@ -715,10 +726,15 @@ func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error { // EmptyTrash permanently deletes all trashed items. // DELETE /api/trash func (h *FSHandler) EmptyTrash(c *fiber.Ctx) error { - if err := os.RemoveAll(h.Config.TrashDir); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to empty trash"}) + if err := safeRemovePath(h.Config.TrashDir, h.Config.TrashDir); err != nil { + // safeRemovePath blocks deleting the root directory, so we just clear its contents instead + entries, err := os.ReadDir(h.Config.TrashDir) + if err == nil { + for _, e := range entries { + os.RemoveAll(filepath.Join(h.Config.TrashDir, e.Name())) + } + } } - os.MkdirAll(h.Config.TrashDir, 0755) h.DB.Exec("DELETE FROM files WHERE is_trashed = 1") h.DB.AddAuditLog("emptied_trash", "User emptied trash", c.IP()) diff --git a/backend/handlers/httpclient.go b/backend/handlers/httpclient.go new file mode 100644 index 0000000..2e5617b --- /dev/null +++ b/backend/handlers/httpclient.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "net/http" + "time" +) + +// SafeHTTPClient is a shared client for all outbound requests. +// It enforces timeouts and prevents indefinite hangs. +var SafeHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + IdleConnTimeout: 30 * time.Second, + MaxIdleConns: 10, + DisableKeepAlives: false, + }, +} diff --git a/backend/handlers/onlyoffice.go b/backend/handlers/onlyoffice.go index a98260b..f611a23 100644 --- a/backend/handlers/onlyoffice.go +++ b/backend/handlers/onlyoffice.go @@ -40,7 +40,7 @@ func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error { } // Validate the token to ensure the callback is authorized - if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, ""); err != nil { + if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path); err != nil { return c.Status(401).SendString("unauthorized") } @@ -60,14 +60,22 @@ func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error { // we can rewrite it to use the internal Docker IP downloadUrl := req.Url + trustedHosts := []string{h.Config.OnlyOfficeHost} + if err := validateCallbackURL(downloadUrl, trustedHosts); err != nil { + return c.Status(403).JSON(fiber.Map{"error": "blocked URL: " + err.Error()}) + } + // Download modified file from Document Server - resp, err := http.Get(downloadUrl) + resp, err := SafeHTTPClient.Get(downloadUrl) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to download file"}) } defer resp.Body.Close() - targetPath := filepath.Join(h.Config.StorageDir, path) + targetPath, err := resolveSafe(h.Config.StorageDir, path) + if err != nil { + return c.Status(403).JSON(fiber.Map{"error": "invalid path"}) + } // Save file out, err := os.Create(targetPath) @@ -145,18 +153,16 @@ func (h *OnlyOfficeHandler) DownloadForEditor(c *fiber.Ctx) error { } // Validate the download token - if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, ""); err != nil { + if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path); err != nil { return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) } // Jail the path to the storage directory - cleaned := filepath.Clean(path) - if filepath.IsAbs(cleaned) || cleaned == ".." || len(cleaned) > 2 && cleaned[:3] == ".." + string(filepath.Separator) { + fullPath, err := resolveSafe(h.Config.StorageDir, path) + if err != nil { return c.Status(403).JSON(fiber.Map{"error": "invalid path"}) } - fullPath := filepath.Join(h.Config.StorageDir, cleaned) - info, err := os.Stat(fullPath) if err != nil || info.IsDir() { return c.Status(404).JSON(fiber.Map{"error": "file not found"}) diff --git a/backend/handlers/sharing.go b/backend/handlers/sharing.go index e492b45..24c05ba 100644 --- a/backend/handlers/sharing.go +++ b/backend/handlers/sharing.go @@ -311,7 +311,7 @@ func (h *ShareHandler) CreateShareToken(c *fiber.Ctx) error { } // Generate a short-lived download token with the share ID - token, err := middleware.GenerateDownloadToken(0, "public_share_"+id, h.Config.JWTSecret) + 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"}) } @@ -331,7 +331,7 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { } // Validate the JWT download token - if err := middleware.ValidateDownloadToken(tokenString, h.Config.JWTSecret, "public_share_"+id); err != nil { + if err := middleware.ValidateDownloadToken(tokenString, h.Config.JWTSecret, "public_share_"+id, ""); err != nil { return c.Status(fiber.StatusUnauthorized).SendString("invalid or expired download token") } diff --git a/backend/handlers/urlvalidation.go b/backend/handlers/urlvalidation.go new file mode 100644 index 0000000..12de642 --- /dev/null +++ b/backend/handlers/urlvalidation.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// validateCallbackURL ensures the URL points to a trusted OnlyOffice Document Server +// and is not targeting internal/private network addresses. +func validateCallbackURL(rawURL string, trustedHosts []string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + // Require HTTPS (or HTTP for Docker-internal communication) + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return fmt.Errorf("unsupported scheme: %s", parsed.Scheme) + } + + // Check against trusted hosts + hostname := parsed.Hostname() + trusted := false + for _, h := range trustedHosts { + if strings.EqualFold(hostname, h) { + trusted = true + break + } + } + if !trusted { + return fmt.Errorf("untrusted host: %s", hostname) + } + + // Resolve DNS and block private/loopback IPs to prevent DNS rebinding + ips, err := net.LookupIP(hostname) + if err != nil { + return fmt.Errorf("DNS resolution failed: %w", err) + } + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() { + // Allow private IPs only if the trusted host is explicitly an IP + if net.ParseIP(hostname) == nil { + return fmt.Errorf("resolved to private IP: %s", ip) + } + } + } + + return nil +} diff --git a/backend/handlers/webdav.go b/backend/handlers/webdav.go index 3eb109d..a7c9a20 100644 --- a/backend/handlers/webdav.go +++ b/backend/handlers/webdav.go @@ -1,7 +1,9 @@ package handlers import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "mime" "net/http" @@ -22,12 +24,13 @@ type WebDAVHandler struct { Handler *webdav.Handler DB *database.DB Config *config.Config + Guard *middleware.BruteForceGuard authCache map[string]time.Time cacheMu sync.RWMutex } -func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler { +func NewWebDAVHandler(db *database.DB, cfg *config.Config, guard *middleware.BruteForceGuard) *WebDAVHandler { fs := webdav.Dir(cfg.StorageDir) // Create a custom locking system (in-memory for now, could be DB backed) @@ -48,6 +51,7 @@ func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler { Handler: h, DB: db, Config: cfg, + Guard: guard, authCache: make(map[string]time.Time), } } @@ -70,7 +74,8 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error { } if hasBasicAuth { - cacheKey := user + ":" + pass + hasher := sha256.Sum256([]byte(user + ":" + pass)) + cacheKey := hex.EncodeToString(hasher[:]) h.cacheMu.RLock() expireTime, ok := h.authCache[cacheKey] @@ -81,20 +86,26 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error { var storedHash string err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash) if err != nil || !verifyPassword(pass, storedHash) { + h.Guard.RecordFailure(c.IP()) c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`) return c.SendStatus(fiber.StatusUnauthorized) } + h.Guard.RecordSuccess(c.IP()) // 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) + // Bounded cache to prevent DoS + if len(h.authCache) >= 1000 { + now := time.Now() + for k, v := range h.authCache { + if now.After(v) { + delete(h.authCache, k) + } } } + if len(h.authCache) < 1000 { + h.authCache[cacheKey] = time.Now().Add(5 * time.Minute) + } h.cacheMu.Unlock() } } else if c.Locals("userID") == nil { diff --git a/backend/main.go b/backend/main.go index 9de8655..2db571c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,10 +2,13 @@ package main import ( "crypto/rand" + "encoding/base64" "encoding/hex" "fmt" "log" "os" + "path/filepath" + "strings" "time" "git.elijahkuntz.com/Elijah/drive/config" @@ -26,12 +29,7 @@ func main() { // Auto-generate JWT secret if not set if cfg.JWTSecret == "" { - secret, err := generateSecret(32) - if err != nil { - log.Fatal("Failed to generate JWT secret:", err) - } - cfg.JWTSecret = secret - log.Println("Warning: JWT_SECRET not set, generated a random one. Set JWT_SECRET env var for persistence across restarts.") + cfg.JWTSecret = getOrCreateJWTSecret(cfg.DataDir) } // Ensure required directories exist @@ -60,12 +58,16 @@ func main() { guard := middleware.NewBruteForceGuard(cfg.MaxLoginAttempts, cfg.LockoutSeconds) // Initialize handlers - authHandler := &handlers.AuthHandler{DB: db, Config: cfg, Guard: guard} + authHandler := handlers.NewAuthHandler(db, cfg, guard) fsHandler := &handlers.FSHandler{DB: db, Config: cfg} healthHandler := &handlers.HealthHandler{DB: db, Config: cfg} settingsHandler := &handlers.SettingsHandler{DB: db} tusHandler := handlers.NewTusHandler(db, cfg) - webdavHandler := handlers.NewWebDAVHandler(db, cfg) + // WebDAV Handler + webdavHandler := handlers.NewWebDAVHandler(db, cfg, guard) + + // Add WebDAV routes + app.All("/webdav/*", guard.Check(), webdavHandler.Handle) archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg} shareGuard := middleware.NewBruteForceGuard(3, 60) shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard} @@ -250,3 +252,33 @@ func generateSecret(length int) (string, error) { } return hex.EncodeToString(bytes), nil } + +func getOrCreateJWTSecret(dataDir string) string { + if secret := os.Getenv("JWT_SECRET"); secret != "" { + return secret + } + + secretPath := filepath.Join(dataDir, ".jwt_secret") + + if data, err := os.ReadFile(secretPath); err == nil { + secret := strings.TrimSpace(string(data)) + if len(secret) >= 32 { + return secret + } + } + + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + log.Fatal("failed to generate JWT secret: ", err) + } + secret := base64.URLEncoding.EncodeToString(buf) + + if err := os.WriteFile(secretPath, []byte(secret), 0600); err != nil { + log.Printf("WARNING: could not persist JWT secret to %s: %v", secretPath, err) + log.Printf("Sessions will be invalidated on next restart.") + } else { + log.Printf("Generated and persisted new JWT secret to %s", secretPath) + } + + return secret +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 5cf541d..e125e43 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -111,7 +111,7 @@ func GenerateRefreshToken(userID int, username, secret string, rememberMe bool) } // GenerateDownloadToken creates a short-lived download token. -func GenerateDownloadToken(userID int, username, secret string) (string, error) { +func GenerateDownloadToken(userID int, username, secret, filePath string) (string, error) { claims := jwt.MapClaims{ "sub": userID, "username": username, @@ -120,12 +120,16 @@ func GenerateDownloadToken(userID int, username, secret string) (string, error) "type": "download", } + if filePath != "" { + claims["path"] = filePath + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(secret)) } // ValidateDownloadToken validates a download token -func ValidateDownloadToken(tokenString, secret, expectedUsername string) error { +func ValidateDownloadToken(tokenString, secret, expectedUsername, expectedPath string) error { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil }) @@ -143,5 +147,12 @@ func ValidateDownloadToken(tokenString, secret, expectedUsername string) error { return fiber.NewError(fiber.StatusUnauthorized, "token belongs to different context") } + if expectedPath != "" { + tokenPath, _ := claims["path"].(string) + if tokenPath != "" && !strings.HasPrefix(expectedPath, tokenPath) { + return fiber.NewError(fiber.StatusUnauthorized, "token not valid for this path") + } + } + return nil } diff --git a/backend/workers/tasks.go b/backend/workers/tasks.go index be3ef10..880dcb5 100644 --- a/backend/workers/tasks.go +++ b/backend/workers/tasks.go @@ -208,9 +208,13 @@ func UnzipAsync(taskID string, sourceFull, destFolder string) { for i, f := range r.File { // Prevent Zip Slip vulnerability - fpath := filepath.Join(destFolder, f.Name) - if !strings.HasPrefix(fpath, filepath.Clean(destFolder)+string(os.PathSeparator)) { - continue // Skip malicious paths + fpath := filepath.Join(destFolder, filepath.FromSlash(f.Name)) + absDestFolder, _ := filepath.Abs(destFolder) + absFpath, _ := filepath.Abs(fpath) + + if !strings.HasPrefix(absFpath, absDestFolder+string(os.PathSeparator)) { + Tasks.FailTask(taskID, fmt.Errorf("zip slip detected: %s attempts to escape to %s", f.Name, absFpath)) + return } if f.FileInfo().IsDir() { diff --git a/backend/workers/thumbnails.go b/backend/workers/thumbnails.go index 5a23ca3..d6a5122 100644 --- a/backend/workers/thumbnails.go +++ b/backend/workers/thumbnails.go @@ -213,17 +213,14 @@ func generateAudioThumbnail(src, dest string) error { func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error { // Generate a download token for the Conversion API to fetch the file from our backend - token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret) + token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret, filePath) if err != nil { return fmt.Errorf("failed to generate download token: %w", err) } // Assuming backend runs on port from config, but we need the external URL or internal docker URL that Document Server can reach. - // Since we saw in OnlyOfficeEditor that the Document Server can reach the host at 192.168.50.81:5827, we use that. - // We'll try to build a robust URL. - hostIP := "192.168.50.81" // Hardcoded as per OnlyOfficeEditor.tsx implementation for this specific deployment - port := cfg.Port - fileUrl := fmt.Sprintf("http://%s:%s/api/public/onlyoffice/download?path=%s&token=%s", hostIP, port, url.QueryEscape(filePath), token) + internalURL := cfg.InternalAPIURL + fileUrl := fmt.Sprintf("%s/public/onlyoffice/download?path=%s&token=%s", internalURL, url.QueryEscape(filePath), token) ext := strings.TrimPrefix(filepath.Ext(filePath), ".") if ext == "" { @@ -344,7 +341,8 @@ func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) erro } // Download the resulting image - imgResp, err := http.Get(finalImgUrl) + client := &http.Client{Timeout: 30 * time.Second} + imgResp, err := client.Get(finalImgUrl) if err != nil { return fmt.Errorf("failed to download converted image: %w", err) } diff --git a/frontend/src/components/OnlyOfficeEditor.tsx b/frontend/src/components/OnlyOfficeEditor.tsx index 3dbf295..d5e8da5 100644 --- a/frontend/src/components/OnlyOfficeEditor.tsx +++ b/frontend/src/components/OnlyOfficeEditor.tsx @@ -88,7 +88,7 @@ export default function OnlyOfficeEditor({ file, type, onClose, onRename, theme, // Port 5827 is the frontend container (which proxies /api/* to the backend). // We use dedicated public endpoints that validate tokens manually, bypassing // the auth middleware which fails when requests come through the Next.js proxy. - const INTERNAL_URL = 'http://192.168.50.81:5827/api'; + const INTERNAL_URL = process.env.NEXT_PUBLIC_API_URL || 'http://192.168.50.81:5827/api'; const fileUrl = `${INTERNAL_URL}/public/onlyoffice/download?path=${encodeURIComponent(file.path)}&token=${downloadToken}`; const callbackUrl = `${INTERNAL_URL}/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${downloadToken}`; @@ -138,7 +138,7 @@ export default function OnlyOfficeEditor({ file, type, onClose, onRename, theme, ); } - const docServerUrl = 'https://office.elijahkuntz.com/'; + const docServerUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_URL || 'https://office.elijahkuntz.com/'; const onDocumentReady = function (event: any) { console.log("Document is loaded");