Phase 3 security audit follow-up. Medium and low-severity hardenings plus a deferred-work plan for the cross-repo stream-token rollout. Stream server CORS: replace the wildcard Access-Control-Allow-Origin with an allowlist that echoes back only torrentclaw.com, app.torrentclaw.com, the local Next dev port (3030 — matches the web repo package.json) and any extras the operator adds via the new downloads.cors_extra_origins TOML key. A Vary: Origin header is now emitted whenever the request carries an Origin header so an intermediate cache cannot serve a stale ACAO to a different origin. URL scheme guard: openBrowser and OpenPlayer refuse any URL that is not http(s). Combined with passing the URL after "--" wherever the launched helper supports it (open, mpv, vlc, cvlc), this stops a leading "-" from being parsed as a switch by the spawned process. State file permissions: WriteState now writes 0o600 so the agent ID, PID and counters cannot be enumerated by another local user on a shared host. Matches the existing config file mode. ZIP slip defense-in-depth: extractZip extracts the safety check into safeZipPath, which canonicalises the entry name (normalising backslashes to "/"), rejects "..", "../" prefix and "/../" interior components, and verifies the final destination stays inside destDir before opening any file. Mirror fallback: documented the design for multi-provider mirrors.json hosting in the comment block on DefaultStaticFallbackURLs and added a follow-up note about signing it with the same ed25519 release key. The list is kept at one provider until the second host is provisioned and added to torrentclaw-web's STATIC_FALLBACKS. Deferred work: a new plan document Docs/plans/security-stream-token.md covers the per-task stream token (Phase 2.2 of the original audit) which requires coordinated web + CLI work and ships separately.
550 lines
15 KiB
Go
550 lines
15 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// readSeekNopCloser envuelve un strings.Reader como ReadSeekCloser.
|
|
type readSeekNopCloser struct {
|
|
*strings.Reader
|
|
}
|
|
|
|
func (r *readSeekNopCloser) Close() error { return nil }
|
|
|
|
func newFakeProvider(name string, content []byte) FileProvider {
|
|
return &fakeFileProviderSeekable{name: name, content: content}
|
|
}
|
|
|
|
// fakeFileProviderSeekable implementa FileProvider con un reader buscable.
|
|
type fakeFileProviderSeekable struct {
|
|
name string
|
|
content []byte
|
|
}
|
|
|
|
func (f *fakeFileProviderSeekable) FileName() string { return f.name }
|
|
func (f *fakeFileProviderSeekable) FileSize() int64 { return int64(len(f.content)) }
|
|
func (f *fakeFileProviderSeekable) NewFileReader(_ context.Context) io.ReadSeekCloser {
|
|
return &readSeekNopCloser{strings.NewReader(string(f.content))}
|
|
}
|
|
|
|
// TestStreamServer_Listen_BindsPort verifica que Listen() enlaza a un puerto
|
|
// y URL() devuelve una URL accesible.
|
|
func TestStreamServer_Listen_BindsPort(t *testing.T) {
|
|
srv := NewStreamServer(0) // puerto aleatorio
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(context.Background())
|
|
|
|
url := srv.URL()
|
|
if url == "" {
|
|
t.Fatal("URL() returned empty string after Listen()")
|
|
}
|
|
if !strings.HasPrefix(url, "http://") {
|
|
t.Errorf("URL() = %q, want http:// prefix", url)
|
|
}
|
|
if srv.Port() == 0 {
|
|
t.Error("Port() should be non-zero after Listen()")
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_Listen_RandomPort verifica que port=0 asigna un puerto disponible.
|
|
func TestStreamServer_Listen_RandomPort(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
port := srv.Port()
|
|
if port <= 0 || port > 65535 {
|
|
t.Errorf("Port() = %d, want valid port 1-65535", port)
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_URL_Format verifica que la URL tiene el formato correcto
|
|
// con host y puerto.
|
|
func TestStreamServer_URL_Format(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
url := srv.URL()
|
|
port := srv.Port()
|
|
|
|
expectedSuffix := fmt.Sprintf(":%d/stream", port)
|
|
if !strings.Contains(url, expectedSuffix) {
|
|
t.Errorf("URL() = %q, want to contain %q", url, expectedSuffix)
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_HasFile verifica que HasFile() refleja el estado correcto.
|
|
func TestStreamServer_HasFile(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
if srv.HasFile() {
|
|
t.Error("HasFile() = true before SetFile(), want false")
|
|
}
|
|
|
|
provider := newFakeProvider("test.mkv", []byte("fake video content"))
|
|
srv.SetFile(provider, "task-123")
|
|
|
|
if !srv.HasFile() {
|
|
t.Error("HasFile() = false after SetFile(), want true")
|
|
}
|
|
|
|
if srv.CurrentTaskID() != "task-123" {
|
|
t.Errorf("CurrentTaskID() = %q, want task-123", srv.CurrentTaskID())
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_ClearFile verifica que ClearFile() elimina el provider actual.
|
|
func TestStreamServer_ClearFile(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
provider := newFakeProvider("video.mkv", []byte("content"))
|
|
srv.SetFile(provider, "task-xyz")
|
|
|
|
srv.ClearFile()
|
|
|
|
if srv.HasFile() {
|
|
t.Error("HasFile() = true after ClearFile(), want false")
|
|
}
|
|
if srv.CurrentTaskID() != "" {
|
|
t.Errorf("CurrentTaskID() = %q, want empty after ClearFile()", srv.CurrentTaskID())
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_NoFile_Returns404 verifica que sin archivo configurado
|
|
// el servidor devuelve 404.
|
|
func TestStreamServer_NoFile_Returns404(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
resp, err := http.Get(srv.URL())
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", srv.URL(), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404 when no file set", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_WithFile_Returns200 verifica que con archivo configurado
|
|
// el servidor sirve el contenido correctamente.
|
|
func TestStreamServer_WithFile_Returns200(t *testing.T) {
|
|
content := []byte("fake video bytes for testing")
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
provider := newFakeProvider("movie.mkv", content)
|
|
srv.SetFile(provider, "task-abc")
|
|
|
|
resp, err := http.Get(srv.URL())
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", srv.URL(), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if len(body) == 0 {
|
|
t.Error("response body is empty, expected file content")
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_Shutdown_ReleasesPort verifica que después de Shutdown()
|
|
// el servidor no sigue respondiendo.
|
|
func TestStreamServer_Shutdown_ReleasesPort(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
|
|
url := srv.URL()
|
|
|
|
// Verificar que funciona antes de Shutdown
|
|
provider := newFakeProvider("test.mkv", []byte("data"))
|
|
srv.SetFile(provider, "t1")
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatalf("GET before shutdown: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Shutdown
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
t.Errorf("Shutdown() error: %v", err)
|
|
}
|
|
|
|
// Después de shutdown, las conexiones deben fallar
|
|
client := &http.Client{Timeout: 500 * time.Millisecond}
|
|
if resp2, getErr := client.Get(url); getErr == nil {
|
|
resp2.Body.Close()
|
|
t.Error("expected error after Shutdown(), server should not be accessible")
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_Concurrent verifica que múltiples requests concurrentes
|
|
// son manejados correctamente.
|
|
func TestStreamServer_Concurrent(t *testing.T) {
|
|
content := []byte("streaming content for concurrent access")
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
provider := newFakeProvider("concurrent.mkv", content)
|
|
srv.SetFile(provider, "task-concurrent")
|
|
|
|
const numRequests = 5
|
|
var wg sync.WaitGroup
|
|
errors := make([]error, numRequests)
|
|
|
|
for i := 0; i < numRequests; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
resp, err := http.Get(srv.URL())
|
|
if err != nil {
|
|
errors[idx] = err
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
errors[idx] = fmt.Errorf("request %d: status %d", idx, resp.StatusCode)
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
for i, err := range errors {
|
|
if err != nil {
|
|
t.Errorf("concurrent request %d failed: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_SetFile_SwapsProvider verifica que SetFile() reemplaza
|
|
// el provider anterior correctamente.
|
|
func TestStreamServer_SetFile_SwapsProvider(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
// Primer archivo
|
|
p1 := newFakeProvider("first.mkv", []byte("first content"))
|
|
srv.SetFile(p1, "task-1")
|
|
|
|
if srv.CurrentTaskID() != "task-1" {
|
|
t.Errorf("after first SetFile: taskID = %q, want task-1", srv.CurrentTaskID())
|
|
}
|
|
|
|
// Swap a segundo archivo
|
|
p2 := newFakeProvider("second.mkv", []byte("second content"))
|
|
srv.SetFile(p2, "task-2")
|
|
|
|
if srv.CurrentTaskID() != "task-2" {
|
|
t.Errorf("after second SetFile: taskID = %q, want task-2", srv.CurrentTaskID())
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_Health_NoFile verifica que /health devuelve streaming:false
|
|
// cuando no hay archivo configurado.
|
|
func TestStreamServer_Health_NoFile(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port())
|
|
resp, err := http.Get(healthURL)
|
|
if err != nil {
|
|
t.Fatalf("GET /health: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if !strings.Contains(ct, "application/json") {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
bodyStr := string(body)
|
|
if !strings.Contains(bodyStr, `"streaming":false`) {
|
|
t.Errorf("body = %q, want streaming:false", bodyStr)
|
|
}
|
|
if !strings.Contains(bodyStr, `"status":"ok"`) {
|
|
t.Errorf("body = %q, want status:ok", bodyStr)
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_Health_WithFile verifica que /health devuelve streaming:true
|
|
// y el nombre del archivo cuando hay un archivo configurado.
|
|
func TestStreamServer_Health_WithFile(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
provider := newFakeProvider("pelicula.mkv", []byte("contenido de prueba"))
|
|
srv.SetFile(provider, "task-health-test")
|
|
|
|
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port())
|
|
resp, err := http.Get(healthURL)
|
|
if err != nil {
|
|
t.Fatalf("GET /health: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
bodyStr := string(body)
|
|
if !strings.Contains(bodyStr, `"streaming":true`) {
|
|
t.Errorf("body = %q, want streaming:true", bodyStr)
|
|
}
|
|
if !strings.Contains(bodyStr, "pelicula.mkv") {
|
|
t.Errorf("body = %q, want file name pelicula.mkv", bodyStr)
|
|
}
|
|
if !strings.Contains(bodyStr, "task-hea") { // primeros 8 chars de "task-health-test"
|
|
t.Errorf("body = %q, want task short ID", bodyStr)
|
|
}
|
|
}
|
|
|
|
// 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) // UPnP off by default — keep test hermetic
|
|
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_CORS_Allowlist verifica que sólo los origenes en la
|
|
// allowlist reciben Access-Control-Allow-Origin y que ningún otro origen
|
|
// es eco-reflejado.
|
|
func TestStreamServer_CORS_Allowlist(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
cases := []struct {
|
|
origin string
|
|
wantAllow bool
|
|
}{
|
|
{"https://app.torrentclaw.com", true},
|
|
{"https://torrentclaw.com", true},
|
|
{"http://localhost:3030", true},
|
|
{"http://127.0.0.1:3030", true},
|
|
{"https://evil.example", false},
|
|
{"null", false},
|
|
{"", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.origin, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodOptions, "/health", nil)
|
|
if tc.origin != "" {
|
|
req.Header.Set("Origin", tc.origin)
|
|
}
|
|
srv.healthHandler(rr, req)
|
|
got := rr.Header().Get("Access-Control-Allow-Origin")
|
|
if tc.wantAllow {
|
|
if got != tc.origin {
|
|
t.Errorf("origin %q: ACAO = %q, want %q", tc.origin, got, tc.origin)
|
|
}
|
|
} else if got != "" {
|
|
t.Errorf("origin %q: ACAO leaked as %q, expected empty", tc.origin, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStreamServer_CORS_ExtraOrigin verifica que SetCORSAllowedOrigins añade
|
|
// origins al baseline sin removerlos.
|
|
func TestStreamServer_CORS_ExtraOrigin(t *testing.T) {
|
|
srv := NewStreamServer(0)
|
|
srv.SetCORSAllowedOrigins([]string{"https://custom.example"})
|
|
ctx := context.Background()
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
for _, origin := range []string{"https://custom.example", "https://torrentclaw.com"} {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
req.Header.Set("Origin", origin)
|
|
srv.healthHandler(rr, req)
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Errorf("origin %q: ACAO = %q", origin, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) // UPnP off by default — keep test hermetic
|
|
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) {
|
|
srv := NewStreamServer(0)
|
|
ctx := context.Background()
|
|
|
|
if err := srv.Listen(ctx); err != nil {
|
|
t.Fatalf("Listen() error: %v", err)
|
|
}
|
|
defer srv.Shutdown(ctx)
|
|
|
|
provider := newFakeProvider("movie.mkv", []byte("mkv content"))
|
|
srv.SetFile(provider, "task-mkv")
|
|
|
|
resp, err := http.Get(srv.URL())
|
|
if err != nil {
|
|
t.Fatalf("GET: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
ct := resp.Header.Get("Content-Type")
|
|
if !strings.Contains(ct, "matroska") && !strings.Contains(ct, "mkv") {
|
|
t.Errorf("Content-Type = %q, want matroska/mkv MIME type", ct)
|
|
}
|
|
}
|