- 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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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