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
|
|
@ -16,9 +16,11 @@ type Config struct {
|
||||||
TrashDir string
|
TrashDir string
|
||||||
VersionsDir string
|
VersionsDir string
|
||||||
BackupDir string
|
BackupDir string
|
||||||
MaxLoginAttempts int
|
|
||||||
LockoutSeconds int
|
LockoutSeconds int
|
||||||
OnlyOfficeJWT string
|
OnlyOfficeJWT string
|
||||||
|
OnlyOfficeHost string
|
||||||
|
PublicURL string
|
||||||
|
InternalAPIURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
|
|
@ -31,6 +33,13 @@ func Load() *Config {
|
||||||
MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5),
|
MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5),
|
||||||
LockoutSeconds: getEnvInt("LOCKOUT_SECONDS", 300), // 5 minutes
|
LockoutSeconds: getEnvInt("LOCKOUT_SECONDS", 300), // 5 minutes
|
||||||
OnlyOfficeJWT: getEnv("ONLYOFFICE_JWT_SECRET", ""),
|
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"
|
cfg.DBPath = cfg.DataDir + "/drive.db"
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,6 @@ func (db *DB) AddAuditLog(action, details, ip string) error {
|
||||||
|
|
||||||
// CleanOldAuditLogs deletes audit logs older than 30 days.
|
// CleanOldAuditLogs deletes audit logs older than 30 days.
|
||||||
func (db *DB) CleanOldAuditLogs() error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ func (h *AuthHandler) GetDownloadToken(c *fiber.Ctx) error {
|
||||||
int(userID.(float64)),
|
int(userID.(float64)),
|
||||||
username.(string),
|
username.(string),
|
||||||
h.Config.JWTSecret,
|
h.Config.JWTSecret,
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate download token"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate download token"})
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -293,12 +294,22 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error {
|
||||||
// For files, attempt to get media metadata
|
// For files, attempt to get media metadata
|
||||||
cat := categorizeFile(filepath.Ext(info.Name()))
|
cat := categorizeFile(filepath.Ext(info.Name()))
|
||||||
if cat == "images" || cat == "videos" || cat == "audio" {
|
if cat == "images" || cat == "videos" || cat == "audio" {
|
||||||
cmd := exec.Command("exiftool", "-json", "--", resolvedPath)
|
ext := strings.ToLower(filepath.Ext(info.Name()))
|
||||||
output, err := cmd.Output()
|
validExts := map[string]bool{
|
||||||
if err == nil {
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
var meta []map[string]interface{}
|
".mp4": true, ".mov": true, ".webm": true, ".mkv": true,
|
||||||
if err := json.Unmarshal(output, &meta); err == nil && len(meta) > 0 {
|
".mp3": true, ".wav": true, ".flac": true, ".m4a": true,
|
||||||
fi.Metadata = meta[0]
|
}
|
||||||
|
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
|
// Remove both potential locations to be safe
|
||||||
os.RemoveAll(resolvedPath)
|
safeRemovePath(h.Config.StorageDir, resolvedPath)
|
||||||
if relativePath != "." {
|
if relativePath != "." {
|
||||||
os.RemoveAll(trashPath)
|
safeRemovePath(h.Config.TrashDir, trashPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanPath := filepath.ToSlash(relativePath)
|
cleanPath := filepath.ToSlash(relativePath)
|
||||||
|
|
@ -584,7 +595,7 @@ func (h *FSHandler) Delete(c *fiber.Ctx) error {
|
||||||
if err := copyFile(resolvedPath, trashPath); err != nil {
|
if err := copyFile(resolvedPath, trashPath); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"})
|
||||||
}
|
}
|
||||||
os.RemoveAll(resolvedPath)
|
safeRemovePath(h.Config.StorageDir, resolvedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update DB
|
// Update DB
|
||||||
|
|
@ -691,7 +702,7 @@ func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error {
|
||||||
if err := copyFile(trashPath, destFull); err != nil {
|
if err := copyFile(trashPath, destFull); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to restore file"})
|
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)
|
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.
|
// EmptyTrash permanently deletes all trashed items.
|
||||||
// DELETE /api/trash
|
// DELETE /api/trash
|
||||||
func (h *FSHandler) EmptyTrash(c *fiber.Ctx) error {
|
func (h *FSHandler) EmptyTrash(c *fiber.Ctx) error {
|
||||||
if err := os.RemoveAll(h.Config.TrashDir); err != nil {
|
if err := safeRemovePath(h.Config.TrashDir, h.Config.TrashDir); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to empty trash"})
|
// 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.Exec("DELETE FROM files WHERE is_trashed = 1")
|
||||||
h.DB.AddAuditLog("emptied_trash", "User emptied trash", c.IP())
|
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
|
// 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")
|
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
|
// we can rewrite it to use the internal Docker IP
|
||||||
downloadUrl := req.Url
|
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
|
// Download modified file from Document Server
|
||||||
resp, err := http.Get(downloadUrl)
|
resp, err := SafeHTTPClient.Get(downloadUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": "failed to download file"})
|
return c.Status(500).JSON(fiber.Map{"error": "failed to download file"})
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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
|
// Save file
|
||||||
out, err := os.Create(targetPath)
|
out, err := os.Create(targetPath)
|
||||||
|
|
@ -145,18 +153,16 @@ func (h *OnlyOfficeHandler) DownloadForEditor(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the download token
|
// 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"})
|
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jail the path to the storage directory
|
// Jail the path to the storage directory
|
||||||
cleaned := filepath.Clean(path)
|
fullPath, err := resolveSafe(h.Config.StorageDir, path)
|
||||||
if filepath.IsAbs(cleaned) || cleaned == ".." || len(cleaned) > 2 && cleaned[:3] == ".." + string(filepath.Separator) {
|
if err != nil {
|
||||||
return c.Status(403).JSON(fiber.Map{"error": "invalid path"})
|
return c.Status(403).JSON(fiber.Map{"error": "invalid path"})
|
||||||
}
|
}
|
||||||
|
|
||||||
fullPath := filepath.Join(h.Config.StorageDir, cleaned)
|
|
||||||
|
|
||||||
info, err := os.Stat(fullPath)
|
info, err := os.Stat(fullPath)
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
return c.Status(404).JSON(fiber.Map{"error": "file not found"})
|
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
|
// 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 {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
|
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
|
// 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")
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -22,12 +24,13 @@ type WebDAVHandler struct {
|
||||||
Handler *webdav.Handler
|
Handler *webdav.Handler
|
||||||
DB *database.DB
|
DB *database.DB
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
|
Guard *middleware.BruteForceGuard
|
||||||
|
|
||||||
authCache map[string]time.Time
|
authCache map[string]time.Time
|
||||||
cacheMu sync.RWMutex
|
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)
|
fs := webdav.Dir(cfg.StorageDir)
|
||||||
|
|
||||||
// Create a custom locking system (in-memory for now, could be DB backed)
|
// 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,
|
Handler: h,
|
||||||
DB: db,
|
DB: db,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
Guard: guard,
|
||||||
authCache: make(map[string]time.Time),
|
authCache: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +74,8 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasBasicAuth {
|
if hasBasicAuth {
|
||||||
cacheKey := user + ":" + pass
|
hasher := sha256.Sum256([]byte(user + ":" + pass))
|
||||||
|
cacheKey := hex.EncodeToString(hasher[:])
|
||||||
|
|
||||||
h.cacheMu.RLock()
|
h.cacheMu.RLock()
|
||||||
expireTime, ok := h.authCache[cacheKey]
|
expireTime, ok := h.authCache[cacheKey]
|
||||||
|
|
@ -81,20 +86,26 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error {
|
||||||
var storedHash string
|
var storedHash string
|
||||||
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
|
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
|
||||||
if err != nil || !verifyPassword(pass, storedHash) {
|
if err != nil || !verifyPassword(pass, storedHash) {
|
||||||
|
h.Guard.RecordFailure(c.IP())
|
||||||
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
|
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
h.Guard.RecordSuccess(c.IP())
|
||||||
|
|
||||||
// Cache successful auth for 5 minutes
|
// Cache successful auth for 5 minutes
|
||||||
h.cacheMu.Lock()
|
h.cacheMu.Lock()
|
||||||
h.authCache[cacheKey] = time.Now().Add(5 * time.Minute)
|
// Bounded cache to prevent DoS
|
||||||
|
if len(h.authCache) >= 1000 {
|
||||||
// Simple cache cleanup
|
now := time.Now()
|
||||||
for k, v := range h.authCache {
|
for k, v := range h.authCache {
|
||||||
if time.Now().After(v) {
|
if now.After(v) {
|
||||||
delete(h.authCache, k)
|
delete(h.authCache, k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(h.authCache) < 1000 {
|
||||||
|
h.authCache[cacheKey] = time.Now().Add(5 * time.Minute)
|
||||||
|
}
|
||||||
h.cacheMu.Unlock()
|
h.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
} else if c.Locals("userID") == nil {
|
} else if c.Locals("userID") == nil {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.elijahkuntz.com/Elijah/drive/config"
|
"git.elijahkuntz.com/Elijah/drive/config"
|
||||||
|
|
@ -26,12 +29,7 @@ func main() {
|
||||||
|
|
||||||
// Auto-generate JWT secret if not set
|
// Auto-generate JWT secret if not set
|
||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
secret, err := generateSecret(32)
|
cfg.JWTSecret = getOrCreateJWTSecret(cfg.DataDir)
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure required directories exist
|
// Ensure required directories exist
|
||||||
|
|
@ -60,12 +58,16 @@ func main() {
|
||||||
guard := middleware.NewBruteForceGuard(cfg.MaxLoginAttempts, cfg.LockoutSeconds)
|
guard := middleware.NewBruteForceGuard(cfg.MaxLoginAttempts, cfg.LockoutSeconds)
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := &handlers.AuthHandler{DB: db, Config: cfg, Guard: guard}
|
authHandler := handlers.NewAuthHandler(db, cfg, guard)
|
||||||
fsHandler := &handlers.FSHandler{DB: db, Config: cfg}
|
fsHandler := &handlers.FSHandler{DB: db, Config: cfg}
|
||||||
healthHandler := &handlers.HealthHandler{DB: db, Config: cfg}
|
healthHandler := &handlers.HealthHandler{DB: db, Config: cfg}
|
||||||
settingsHandler := &handlers.SettingsHandler{DB: db}
|
settingsHandler := &handlers.SettingsHandler{DB: db}
|
||||||
tusHandler := handlers.NewTusHandler(db, cfg)
|
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}
|
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
|
||||||
shareGuard := middleware.NewBruteForceGuard(3, 60)
|
shareGuard := middleware.NewBruteForceGuard(3, 60)
|
||||||
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard}
|
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard}
|
||||||
|
|
@ -250,3 +252,33 @@ func generateSecret(length int) (string, error) {
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(bytes), nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ func GenerateRefreshToken(userID int, username, secret string, rememberMe bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateDownloadToken creates a short-lived download token.
|
// 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{
|
claims := jwt.MapClaims{
|
||||||
"sub": userID,
|
"sub": userID,
|
||||||
"username": username,
|
"username": username,
|
||||||
|
|
@ -120,12 +120,16 @@ func GenerateDownloadToken(userID int, username, secret string) (string, error)
|
||||||
"type": "download",
|
"type": "download",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if filePath != "" {
|
||||||
|
claims["path"] = filePath
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString([]byte(secret))
|
return token.SignedString([]byte(secret))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateDownloadToken validates a download token
|
// 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) {
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
return []byte(secret), nil
|
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")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,9 +208,13 @@ func UnzipAsync(taskID string, sourceFull, destFolder string) {
|
||||||
|
|
||||||
for i, f := range r.File {
|
for i, f := range r.File {
|
||||||
// Prevent Zip Slip vulnerability
|
// Prevent Zip Slip vulnerability
|
||||||
fpath := filepath.Join(destFolder, f.Name)
|
fpath := filepath.Join(destFolder, filepath.FromSlash(f.Name))
|
||||||
if !strings.HasPrefix(fpath, filepath.Clean(destFolder)+string(os.PathSeparator)) {
|
absDestFolder, _ := filepath.Abs(destFolder)
|
||||||
continue // Skip malicious paths
|
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() {
|
if f.FileInfo().IsDir() {
|
||||||
|
|
|
||||||
|
|
@ -213,17 +213,14 @@ func generateAudioThumbnail(src, dest string) error {
|
||||||
|
|
||||||
func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) 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
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate download token: %w", err)
|
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.
|
// 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.
|
internalURL := cfg.InternalAPIURL
|
||||||
// We'll try to build a robust URL.
|
fileUrl := fmt.Sprintf("%s/public/onlyoffice/download?path=%s&token=%s", internalURL, url.QueryEscape(filePath), token)
|
||||||
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)
|
|
||||||
|
|
||||||
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
|
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
|
|
@ -344,7 +341,8 @@ func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the resulting image
|
// Download the resulting image
|
||||||
imgResp, err := http.Get(finalImgUrl)
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
imgResp, err := client.Get(finalImgUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download converted image: %w", err)
|
return fmt.Errorf("failed to download converted image: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// Port 5827 is the frontend container (which proxies /api/* to the backend).
|
||||||
// We use dedicated public endpoints that validate tokens manually, bypassing
|
// We use dedicated public endpoints that validate tokens manually, bypassing
|
||||||
// the auth middleware which fails when requests come through the Next.js proxy.
|
// 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 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}`;
|
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) {
|
const onDocumentReady = function (event: any) {
|
||||||
console.log("Document is loaded");
|
console.log("Document is loaded");
|
||||||
|
|
|
||||||
Reference in a new issue