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

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