Phase 1a: browser-side security
continuous-integration/drone/push Build encountered an error

- 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:
2026-09-26 12:59:47 +10:00
parent 504ea3f9f4
commit 4976219857
8 changed files with 238 additions and 102 deletions
+14 -2
View File
@@ -503,9 +503,21 @@ loopToken:
}
// SetCookieToken : set cookie
// SetCookieToken : sets the session cookie. It is HttpOnly (scripts cannot
// read it), SameSite=Strict, and a session cookie: expiry is enforced server
// side per token and refreshed on every authenticated request. A token of
// "-" clears the cookie (logout).
func SetCookieToken(w http.ResponseWriter, token string) http.ResponseWriter {
expiration := time.Now().Add(time.Minute * time.Duration(tokenValidity))
cookie := http.Cookie{Name: "Token", Value: token, Expires: expiration}
cookie := http.Cookie{
Name: "Token",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
if token == "-" {
cookie.MaxAge = -1
}
http.SetCookie(w, &cookie)
return w
}
+32 -25
View File
@@ -15,12 +15,29 @@ import (
"github.com/gorilla/websocket"
)
// wsUpgrader uses gorilla's default CheckOrigin: an Origin header, when
// present, must match the request Host. That blocks cross-site websocket
// hijacking from other pages on the LAN while still allowing non-browser
// clients that send no Origin.
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// wsSessionToken : session token for a websocket request. The HttpOnly
// cookie set at login is preferred; the legacy ?Token= query parameter is
// still accepted for older clients.
func wsSessionToken(r *http.Request) string {
if c, err := r.Cookie("Token"); err == nil && len(c.Value) > 0 {
return c.Value
}
if v := r.URL.Query().Get("Token"); len(v) > 0 {
return v
}
return "-"
}
// StartWebserver : Startet den Webserver
@@ -313,10 +330,10 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
*/
// Upgrade writes its own error response (e.g. 403 for a bad Origin).
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
ShowError(err, 0)
http.Error(w, "Could not open websocket connection", http.StatusBadRequest)
return
}
@@ -337,16 +354,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
// Token Authentication
case true:
var token string
tokens, ok := r.URL.Query()["Token"]
if !ok || len(tokens[0]) < 1 {
token = "-"
} else {
token = tokens[0]
}
newToken, err = tokenAuthentication(token)
newToken, err = tokenAuthentication(wsSessionToken(r))
if err != nil {
response.Status = false
@@ -602,22 +610,21 @@ func Web(w http.ResponseWriter, r *http.Request) {
}
switch System.ConfigurationWizard {
case true:
file = "configuration.html"
Settings.AuthenticationWEB = false
case false:
file = "index.html"
}
file = "index.html"
if System.ScanInProgress == 1 {
file = "maintenance.html"
}
switch Settings.AuthenticationWEB {
// The first-run wizard is reachable without a login (there is nothing
// to protect yet); it must not switch authentication off in Settings.
var requireLogin = Settings.AuthenticationWEB && !System.ConfigurationWizard
if System.ConfigurationWizard {
file = "configuration.html"
}
switch requireLogin {
case true:
var username, password, confirm string
+139
View File
@@ -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)")
}
}