chore: implement Phase 1 and 2 security remediations
Some checks failed
Automated Container Build / build-and-push (push) Has been cancelled
Some checks failed
Automated Container Build / build-and-push (push) Has been cancelled
This commit is contained in:
parent
82731e93b1
commit
701766b611
14 changed files with 213 additions and 55 deletions
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
19
backend/handlers/httpclient.go
Normal file
19
backend/handlers/httpclient.go
Normal 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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
51
backend/handlers/urlvalidation.go
Normal file
51
backend/handlers/urlvalidation.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Reference in a new issue