fix(security): harden HLS session IDs, /health disclosure, archive password handling

Phase 1 security audit follow-up:

- Reject HLS session IDs that aren't safe filesystem components
  (regex allowlist) to defend against path traversal via a buggy or
  compromised server. Applied at StartHLSSession and at the /hls URL
  handler; invalid IDs share the 404 of unknown sessions so the
  accepted format isn't enumerable.
- /health no longer leaks the active filename, taskID prefix or client
  IP to non-loopback callers. Uses net.IP.IsLoopback so IPv4-mapped
  IPv6 (::ffff:127.0.0.1) is recognised and the empty-string parse
  failure stops bypassing the boundary.
- unrar/7z passwords now travel through stdin instead of -p<password>
  in argv, removing /proc/<pid>/cmdline disclosure. Control characters
  in the password are rejected up front so a hostile NZB cannot feed
  extra prompt answers. Both invocations are bounded by a 30-minute
  context to stop indefinite hangs if the tool ever decides to prompt.
This commit is contained in:
Deivid Soto 2026-05-15 17:10:42 +02:00
parent a73e1a7756
commit c148cb8ce7
6 changed files with 213 additions and 16 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
@ -379,6 +380,86 @@ func TestStreamServer_Health_WithFile(t *testing.T) {
}
}
// TestStreamServer_Health_NonLoopback_NoLeak verifica que /health no revela
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
srv := NewStreamServer(0)
srv.disableUPnP = true
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("secret.mkv", []byte("data"))
srv.SetFile(provider, "secret-task-id")
cases := []struct {
name string
remoteAddr string
}{
{"lan_ipv4", "192.168.1.50:54321"},
{"empty_host_no_bypass", ":54321"},
{"public_ipv4", "203.0.113.10:443"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.RemoteAddr = tc.remoteAddr
srv.healthHandler(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Errorf("body missing status:ok: %q", body)
}
if !strings.Contains(body, `"streaming":true`) {
t.Errorf("body should report streaming bool: %q", body)
}
if strings.Contains(body, "secret.mkv") {
t.Errorf("body leaked filename: %q", body)
}
if strings.Contains(body, "secret-t") {
t.Errorf("body leaked task id: %q", body)
}
if strings.Contains(body, "192.168.1.50") || strings.Contains(body, "203.0.113.10") {
t.Errorf("body leaked client ip: %q", body)
}
})
}
}
// TestStreamServer_HLS_InvalidSessionID verifica que el hlsHandler rechaza
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
// inexistente) para no filtrar el formato aceptado a un attacker.
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
srv := NewStreamServer(0)
srv.disableUPnP = true
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
bad := []string{
"/hls/..%2Fetc%2Fpasswd/master.m3u8",
"/hls/foo.bar/master.m3u8",
"/hls/foo%20bar/master.m3u8",
"/hls/foo%2Fbar/master.m3u8",
}
for _, path := range bad {
t.Run(path, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
srv.hlsHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("path %q: status = %d, want 404", path, rr.Code)
}
})
}
}
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
// es el correcto.
func TestStreamServer_MKV_ContentType(t *testing.T) {