filebrowser/http/upload_cache_memory.go
Henrique Dias 9bd79c3aae
fix(http): delete abandoned TUS uploads through the scoped filesystem
The in-memory upload cache deleted expired incomplete uploads with a raw
os.Remove on the absolute path it had stored, bypassing ScopedFs entirely.
Because the stored path is only lexically cleaned (no symlink evaluation) and
os.Remove resolves symlinked parent directories, a Create-only user could
register an upload and then, within the 3-minute TTL, swap an in-scope
ancestor directory for a symlink so the eviction deleted an arbitrary file
outside their scope.

Carry a removal callback with each cache entry and invoke it on eviction
instead of os.Remove. For TUS uploads the callback deletes via the uploading
user's scoped filesystem, whose Remove is guarded by the same within() check
that CVE-2026-55667 added, so eviction can no longer follow a symlink out of
scope. The redis backend ignores the callback (it never deleted partial files).

Refs GHSA-m9f5-2232-frp6
2026-07-25 08:16:18 +02:00

101 lines
3.2 KiB
Go

package fbhttp
import (
"context"
"fmt"
"time"
"github.com/jellydator/ttlcache/v3"
)
const uploadCacheTTL = 3 * time.Minute
// UploadCache is an interface for tracking active uploads.
// Allows for different backends (e.g. in-memory or redis)
// to support both single instance and multi replica deployments.
type UploadCache interface {
// Register stores an upload with its expected file size. remove is called if
// the upload expires before completion, to delete the partial file; it must
// route through the uploading user's scoped filesystem so that eviction
// cannot follow a symlink out of the user's scope.
Register(filePath string, fileSize int64, remove func() error)
// Complete removes an upload from the cache
Complete(filePath string)
// GetLength returns the expected file size for an active upload
GetLength(filePath string) (int64, error)
// Touch refreshes the TTL for an active upload
Touch(filePath string)
// Close cleans up any resources
Close()
}
// memoryUploadEntry is the value stored for each active upload.
type memoryUploadEntry struct {
size int64
remove func() error
}
// memoryUploadCache is an upload cache for single replica deployments
type memoryUploadCache struct {
cache *ttlcache.Cache[string, memoryUploadEntry]
}
func newMemoryUploadCache() *memoryUploadCache {
cache := ttlcache.New[string, memoryUploadEntry]()
cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, memoryUploadEntry]) {
if reason == ttlcache.EvictionReasonExpired {
fmt.Printf("deleting incomplete upload file: \"%s\"\n", item.Key())
// Delete through the scoped removal callback rather than a raw
// os.Remove on the cached path, so an ancestor directory swapped for
// a symlink during the TTL window cannot redirect the delete outside
// the user's scope.
if remove := item.Value().remove; remove != nil {
if err := remove(); err != nil {
fmt.Printf("failed to delete incomplete upload file %q: %v\n", item.Key(), err)
}
}
}
})
go cache.Start()
return &memoryUploadCache{cache: cache}
}
func (c *memoryUploadCache) Register(filePath string, fileSize int64, remove func() error) {
c.cache.Set(filePath, memoryUploadEntry{size: fileSize, remove: remove}, uploadCacheTTL)
}
func (c *memoryUploadCache) Complete(filePath string) {
c.cache.Delete(filePath)
}
func (c *memoryUploadCache) GetLength(filePath string) (int64, error) {
item := c.cache.Get(filePath)
if item == nil {
return 0, fmt.Errorf("no active upload found for the given path")
}
return item.Value().size, nil
}
func (c *memoryUploadCache) Touch(filePath string) {
c.cache.Touch(filePath)
}
func (c *memoryUploadCache) Close() {
c.cache.Stop()
}
// NewUploadCache creates a new upload cache.
// If redisURL is empty, an in-memory cache will be used (suitable for single instance deployments).
// Otherwise, Redis will be used for the cache (suitable for multi-instance deployments).
// The redisURL can include credentials, e.g. redis://user:pass@host:port
func NewUploadCache(redisURL string) (UploadCache, error) {
if redisURL != "" {
return newRedisUploadCache(redisURL)
}
return newMemoryUploadCache(), nil
}