Files
xTeVe/src/websocket_test.go
T
nathan 4976219857
continuous-integration/drone/push Build encountered an error
Phase 1a: browser-side security
- Provider-controlled strings (channel names, groups, file names, log
  lines, in-place mapping edits, client info) are rendered with
  textContent instead of innerHTML. PopupContent.description() takes an
  explicit isHTML flag that only the static help texts pass.
- Websocket: drop the always-true CheckOrigin so gorilla's same-origin
  check applies; read the session token from the HttpOnly cookie sent with
  the handshake (the ?Token= query parameter is still accepted for older
  clients); the client no longer puts the token in the URL, rewrites the
  cookie, or console-logs request/response payloads.
- Session cookie is HttpOnly, SameSite=Strict, Path=/, session-scoped
  (expiry stays server side) and is cleared on logout.
- Serving the first-run wizard no longer sets Settings.AuthenticationWEB
  to false; the wizard page simply bypasses login while it is active.
- Upgrade failures no longer write a second error response.
- Tests: src/websocket_test.go covers cross-origin refusal, same-origin
  and no-Origin clients, missing/unknown/legacy tokens, and cookie flags.
2026-09-26 12:59:47 +10:00

140 lines
3.9 KiB
Go

package src
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/websocket"
"xteve/src/internal/authentication"
)
// newAuthenticatedWSServer starts the websocket handler with web
// authentication enabled and returns the server plus a valid session token.
func newAuthenticatedWSServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
if err := authentication.Init(t.TempDir()+"/authentication.json", 60); err != nil {
t.Fatalf("authentication.Init: %v", err)
}
if _, err := authentication.CreateNewUser("admin", "secret"); err != nil {
t.Fatalf("CreateNewUser: %v", err)
}
token, err := authentication.UserAuthentication("admin", "secret")
if err != nil {
t.Fatalf("UserAuthentication: %v", err)
}
Settings.AuthenticationWEB = true
System.ConfigurationWizard = false
t.Cleanup(func() { Settings.AuthenticationWEB = false })
srv := httptest.NewServer(http.HandlerFunc(WS))
t.Cleanup(srv.Close)
return srv, token
}
func wsURL(srv *httptest.Server) string {
return "ws" + strings.TrimPrefix(srv.URL, "http") + "/data/"
}
// roundTrip sends one command and returns the parsed response.
func roundTrip(t *testing.T, url string, header http.Header) ResponseStruct {
t.Helper()
conn, resp, err := websocket.DefaultDialer.Dial(url, header)
if err != nil {
t.Fatalf("dial: %v (resp=%v)", err, resp)
}
defer conn.Close()
if err := conn.WriteJSON(map[string]any{"cmd": "noop"}); err != nil {
t.Fatalf("write: %v", err)
}
var response ResponseStruct
if err := conn.ReadJSON(&response); err != nil {
t.Fatalf("read: %v", err)
}
return response
}
func TestWSRejectsCrossOrigin(t *testing.T) {
srv, _ := newAuthenticatedWSServer(t)
header := http.Header{"Origin": {"http://evil.example"}}
_, resp, err := websocket.DefaultDialer.Dial(wsURL(srv), header)
if err == nil {
t.Fatal("expected the cross-origin upgrade to be refused")
}
if resp == nil || resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %v", resp)
}
}
func TestWSAllowsSameOriginAndNoOrigin(t *testing.T) {
srv, token := newAuthenticatedWSServer(t)
sameOrigin := http.Header{
"Origin": {srv.URL},
"Cookie": {"Token=" + token},
}
if r := roundTrip(t, wsURL(srv), sameOrigin); !r.Status {
t.Fatalf("same-origin request with cookie should succeed, got error %q", r.Error)
}
noOrigin := http.Header{"Cookie": {"Token=" + token}}
if r := roundTrip(t, wsURL(srv), noOrigin); !r.Status {
t.Fatalf("request without Origin (non-browser client) should succeed, got error %q", r.Error)
}
}
func TestWSRequiresSessionToken(t *testing.T) {
srv, token := newAuthenticatedWSServer(t)
r := roundTrip(t, wsURL(srv), nil)
if r.Status || !r.Reload {
t.Fatalf("request without a token should be refused with reload, got status=%v reload=%v", r.Status, r.Reload)
}
bad := http.Header{"Cookie": {"Token=not-a-real-token"}}
if r := roundTrip(t, wsURL(srv), bad); r.Status {
t.Fatal("request with an unknown token should be refused")
}
// Legacy clients may still pass the token as a query parameter.
if r := roundTrip(t, wsURL(srv)+"?Token="+token, nil); !r.Status {
t.Fatalf("legacy query-parameter token should be accepted, got error %q", r.Error)
}
}
func TestSetCookieTokenFlags(t *testing.T) {
rec := httptest.NewRecorder()
authentication.SetCookieToken(rec, "abc")
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "Token" || c.Value != "abc" {
t.Fatalf("unexpected cookie %v", c)
}
if !c.HttpOnly {
t.Error("session cookie must be HttpOnly")
}
if c.SameSite != http.SameSiteStrictMode {
t.Error("session cookie must be SameSite=Strict")
}
if c.Path != "/" {
t.Errorf("cookie path should be /, got %q", c.Path)
}
rec = httptest.NewRecorder()
authentication.SetCookieToken(rec, "-")
if c := rec.Result().Cookies()[0]; c.MaxAge >= 0 {
t.Error("logout must clear the cookie (negative MaxAge)")
}
}