fix: resolve deadlock, data races and path traversal vulnerabilities

- task.go: fix deadlock in ToStatusUpdate() — calling Percent() (which
  RLocks) while already holding RLock caused deadlock when a writer was
  waiting; compute percent inline instead
- usenet.go: fix data race in Cancel() — tracker and taskDir were read
  without the mutex while Download() writes them under it; read all
  fields under the same lock
- upnp.go: fix UPnP Remove() blocking shutdown — run cleanup in goroutine
  with 10s deadline (removeNATPMP worst case is 3s dial + 5s deadline)
- daemon.go: add path traversal protection for stream requests — validate
  sr.FilePath is within configured directories before os.Stat; defends
  against compromised API server sending arbitrary paths
- client.go: add wakeClient without timeout for long-poll wake endpoint
  where context controls cancellation
- sync.go: trigger immediate sync when entering watching mode so stream
  requests are picked up without waiting for the next scheduled interval
This commit is contained in:
Deivid Soto 2026-04-08 23:36:18 +02:00
parent 78c16c295e
commit ef4f38d324
6 changed files with 146 additions and 13 deletions

View file

@ -207,10 +207,20 @@ func (t *Task) ToStatusUpdate() agent.StatusUpdate {
// StatusPending, StatusClaimed, StatusCancelled — not reported
}
// Compute percent inline — do NOT call t.Percent() here since we already hold RLock.
// Calling Percent() (which also RLocks) while holding RLock deadlocks when a writer is waiting.
percent := 0
if t.TotalBytes > 0 {
percent = int(float64(t.DownloadedBytes) / float64(t.TotalBytes) * 100)
if percent > 100 {
percent = 100
}
}
return agent.StatusUpdate{
TaskID: t.ID,
Status: apiStatus,
Progress: t.Percent(),
Progress: percent,
DownloadedBytes: t.DownloadedBytes,
TotalBytes: t.TotalBytes,
SpeedBps: t.SpeedBps,

View file

@ -338,16 +338,28 @@ func localIPFor(host string) string {
}
// Remove deletes the port mapping from the router.
// It runs in a goroutine with a 5-second deadline so it never blocks shutdown.
func (m *UPnPMapping) Remove() {
if m == nil {
return
}
switch m.protocol {
case "natpmp":
m.removeNATPMP()
case "upnp":
m.removeUPnP()
done := make(chan struct{})
go func() {
defer close(done)
switch m.protocol {
case "natpmp":
m.removeNATPMP()
case "upnp":
m.removeUPnP()
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
// removeNATPMP worst case: 3s dial + 5s natpmpMapPort deadline = 8s.
// 10s gives enough margin without blocking shutdown indefinitely.
log.Printf("stream: UPnP/NAT-PMP cleanup timed out after 10s — port %d may remain mapped", m.ExternalPort)
}
}

View file

@ -300,8 +300,16 @@ func (u *UsenetDownloader) Pause(taskID string) error {
// Cancel aborts an in-progress download and removes partial files + resume state.
func (u *UsenetDownloader) Cancel(taskID string) error {
// Read all fields under the lock — Download() writes tracker and taskDir under
// the same lock, so we must hold it while reading to avoid a data race.
u.mu.Lock()
dl := u.active[taskID]
var tracker *download.ProgressTracker
var taskDir string
if dl != nil {
tracker = dl.tracker
taskDir = dl.taskDir
}
u.mu.Unlock()
if dl == nil {
@ -312,13 +320,13 @@ func (u *UsenetDownloader) Cancel(taskID string) error {
dl.cancel()
// Remove resume state (best-effort)
if dl.tracker != nil {
dl.tracker.Remove()
if tracker != nil {
tracker.Remove()
}
// Remove partial download directory in background (can be slow for large dirs)
if dl.taskDir != "" {
go os.RemoveAll(dl.taskDir)
if taskDir != "" {
go os.RemoveAll(taskDir)
}
return nil