- 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.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user