chore: implement Phase 1 and 2 security remediations
Some checks failed
Automated Container Build / build-and-push (push) Has been cancelled

This commit is contained in:
Elijah 2026-05-26 13:32:09 -07:00
parent 82731e93b1
commit 701766b611
14 changed files with 213 additions and 55 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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