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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Reference in a new issue