package main import ( "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) func cctvFixture(t *testing.T, target string) (*Server, http.Handler) { t.Helper() s := authServer(t, "") s.cctvSess = newSessionStore("").set("cctv") s.cctvSess.add("test-cctv-session", cctvTTL) h, err := s.cctvMux(target) if err != nil { t.Fatal(err) } return s, h } func cctvCall(h http.Handler, method, path, body string, loggedIn bool, origin string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, "https://cctv.example.org"+path, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") if origin != "" { r.Header.Set("Origin", origin) } if loggedIn { r.AddCookie(&http.Cookie{Name: "wxch_cctv", Value: "test-cctv-session"}) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func TestCCTVAuthenticationCSRFAndUnavailable(t *testing.T) { s, h := cctvFixture(t, "") if appForHost("cctv.wxch.org") != "cctv" { t.Fatal("CCTV host not routed") } for _, path := range []string{"/", "/api/health", "/api/saved", "/api/webrtc", "/_wxch/status"} { if w := cctvCall(h, "GET", path, "", false, ""); w.Code != 401 { t.Fatalf("anonymous %s: %d", path, w.Code) } } r := httptest.NewRequest("GET", "https://cctv.example.org/", nil) r.Header.Set("Accept", "text/html") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != 200 || !strings.Contains(w.Body.String(), "/_wxch/login") || strings.Contains(w.Body.String(), "@@") || !strings.Contains(w.Header().Get("Content-Security-Policy"), "nonce-") { t.Fatal("login page or CSP missing") } if w := cctvCall(h, "GET", "/api/health", "", true, ""); w.Code != 503 { t.Fatal("missing upstream falsely reported ready") } if w := cctvCall(h, "GET", "/_wxch/status", "", true, ""); !strings.Contains(w.Body.String(), `"upstream_configured":false`) { t.Fatal("incorrect readiness") } for _, origin := range []string{"", "https://evil.example.org", "http://cctv.example.org"} { if w := cctvCall(h, "POST", "/api/webrtc", "{}", true, origin); w.Code != 403 { t.Fatalf("accepted unsafe origin %q", origin) } } if w := cctvCall(h, "GET", "/api/health", "", true, "https://evil.example.org"); w.Code != 403 { t.Fatal("accepted cross-origin read") } if w := cctvCall(h, "POST", "/_wxch/logout", "{}", true, "https://cctv.example.org"); w.Code != 200 || s.cctvSess.valid("test-cctv-session") { t.Fatal("logout failed") } } func TestCCTVLoginScopedAndTwoFactor(t *testing.T) { s, h := cctvFixture(t, "") s.cfg.TOTPSecret = "JBSWY3DPEHPK3PXP" body, _ := json.Marshal(map[string]string{"Password": testPassword}) if w := cctvCall(h, "POST", "/_wxch/login", string(body), false, "https://evil.example.org"); w.Code != 403 { t.Fatal("login CSRF accepted") } if w := cctvCall(h, "POST", "/_wxch/login", string(body), false, "https://cctv.example.org"); w.Code != 401 { t.Fatal("2FA bypass") } body, _ = json.Marshal(map[string]string{"Password": testPassword, "Code": totp(s.cfg.TOTPSecret, time.Now())}) w := cctvCall(h, "POST", "/_wxch/login", string(body), false, "https://cctv.example.org") if w.Code != 200 || len(w.Result().Cookies()) != 1 { t.Fatal("valid login failed") } cookie := w.Result().Cookies()[0] if cookie.Name != "wxch_cctv" || cookie.Domain != "" || !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode || !s.cctvSess.valid(cookie.Value) { t.Fatal("unsafe session cookie") } r := httptest.NewRequest("GET", "https://control.example.org/api/me", nil) r.AddCookie(cookie) if s.authed(r) { t.Fatal("CCTV session granted Control access") } } func TestCCTVProxyPreservesOriginAndStripsOwnerCredentials(t *testing.T) { seen := make(chan *http.Request, 1) backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen <- r.Clone(r.Context()) w.Header().Add("Set-Cookie", "wxch_cctv=attacker; Path=/") w.Header().Add("Set-Cookie", "app_session=allowed; Path=/") w.Header().Set("Cache-Control", "public") w.Write([]byte("stream-test")) })) defer backend.Close() _, h := cctvFixture(t, backend.URL) r := httptest.NewRequest("POST", "https://cctv.example.org/api/scan", strings.NewReader("{}")) r.Header.Set("Origin", "https://cctv.example.org") r.Header.Set("Cookie", "wxch_cctv=test-cctv-session; wxch_session=owner-secret; app_session=keep") r.Header.Set("Authorization", "Bearer owner-secret") r.Header.Set("Forwarded", "host=evil.example") r.Header.Set("X-Forwarded-Host", "evil.example") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != 200 || w.Body.String() != "stream-test" || w.Header().Get("Cache-Control") != "no-store" { t.Fatal("proxy failed") } got := <-seen if got.Host != "cctv.example.org" || got.Header.Get("Origin") != "https://cctv.example.org" || got.URL.Path != "/api/scan" { t.Fatal("Host/Origin/path changed") } if got.Header.Get("Cookie") != "app_session=keep" || got.Header.Get("Authorization") != "" || got.Header.Get("Forwarded") != "" || got.Header.Get("X-Forwarded-Host") != "" { t.Fatal("owner credential or spoofed forwarding headers leaked") } cookies := w.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != "app_session" { t.Fatal("backend overwrote gateway session") } backend.Close() w = cctvCall(h, "GET", "/api/health", "", true, "") if w.Code != 503 { t.Fatal("unreachable upstream reported ready") } io.Copy(io.Discard, w.Result().Body) } func TestCCTVUpstreamValidation(t *testing.T) { for _, value := range []string{"https://127.0.0.1:18091", "http://localhost:18091", "http://192.0.2.1:8091", "http://127.0.0.1", "http://127.0.0.1:0", "http://user:secret@127.0.0.1:8091", "http://127.0.0.1:8091/path", "http://127.0.0.1:8091?secret=x"} { if _, err := cctvTarget(value); err == nil { t.Fatalf("accepted unsafe upstream %q", value) } } for _, value := range []string{"", "http://127.0.0.1:18091", "http://[::1]:18091"} { if _, err := cctvTarget(value); err != nil { t.Fatal(err) } } s := authServer(t, "") s.cfg.Listen = "127.0.0.1:8080" if _, err := s.cctvMux("http://127.0.0.1:8080"); err == nil { t.Fatal("accepted self-proxy loop") } }