From fee9d3b1a86f36eb03e47c8ea4d8a98501e4682d Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:11:38 +0000 Subject: [PATCH] Refuse input from sessions that cannot host --- server/internal/session/session.go | 6 +- server/internal/session/session_test.go | 41 +++++ server/internal/webrtc/legacyhandler_test.go | 70 +++++++++ .../internal/websocket/handler/clipboard.go | 9 +- server/internal/websocket/handler/control.go | 7 +- .../websocket/handler/control_test.go | 147 ++++++++++++++++++ server/internal/websocket/handler/keyboard.go | 10 +- 7 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 server/internal/session/session_test.go create mode 100644 server/internal/webrtc/legacyhandler_test.go create mode 100644 server/internal/websocket/handler/control_test.go diff --git a/server/internal/session/session.go b/server/internal/session/session.go index 95a5ad654..686c9611a 100644 --- a/server/internal/session/session.go +++ b/server/internal/session/session.go @@ -75,8 +75,10 @@ func (session *SessionCtx) IsHost() bool { // only needed for legacy webrtc handler func (session *SessionCtx) LegacyIsHost() bool { - implicitHosting := session.manager.Settings().ImplicitHosting - return !(!implicitHosting && !session.manager.isHost(session)) || (implicitHosting && !session.profile.CanHost) + if !session.profile.CanHost { + return false + } + return session.manager.Settings().ImplicitHosting || session.manager.isHost(session) } func (session *SessionCtx) SetAsHost() { diff --git a/server/internal/session/session_test.go b/server/internal/session/session_test.go new file mode 100644 index 000000000..2a2dddac6 --- /dev/null +++ b/server/internal/session/session_test.go @@ -0,0 +1,41 @@ +package session + +import ( + "testing" + + "github.com/m1k1o/neko/server/internal/config" + "github.com/m1k1o/neko/server/pkg/types" +) + +func TestLegacyIsHost(t *testing.T) { + tests := []struct { + name string + implicitHosting bool + canHost bool + setHost bool + want bool + }{ + {name: "implicit hosting, can host", implicitHosting: true, canHost: true, want: true}, + {name: "implicit hosting, cannot host", implicitHosting: true, canHost: false, want: false}, + {name: "explicit hosting, host", canHost: true, setHost: true, want: true}, + {name: "explicit hosting, not host", canHost: true, want: false}, + {name: "explicit hosting, cannot host", canHost: false, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := New(&config.Session{ImplicitHosting: tt.implicitHosting}) + session, _, err := manager.Create("member", types.MemberProfile{CanHost: tt.canHost}) + if err != nil { + t.Fatalf("create session: %v", err) + } + if tt.setHost { + session.SetAsHost() + } + + if got := session.LegacyIsHost(); got != tt.want { + t.Errorf("LegacyIsHost() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/server/internal/webrtc/legacyhandler_test.go b/server/internal/webrtc/legacyhandler_test.go new file mode 100644 index 000000000..1b754b8d3 --- /dev/null +++ b/server/internal/webrtc/legacyhandler_test.go @@ -0,0 +1,70 @@ +package webrtc + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/rs/zerolog" + + "github.com/m1k1o/neko/server/internal/config" + "github.com/m1k1o/neko/server/internal/session" + "github.com/m1k1o/neko/server/pkg/types" +) + +type recordingDesktop struct { + types.DesktopManager + calls []string +} + +func (d *recordingDesktop) Move(x, y int) { d.calls = append(d.calls, "Move") } +func (d *recordingDesktop) KeyDown(code uint32) error { + d.calls = append(d.calls, "KeyDown") + return nil +} +func (d *recordingDesktop) ButtonDown(code uint32) error { + d.calls = append(d.calls, "ButtonDown") + return nil +} + +func legacyPayload(t *testing.T, payload any) []byte { + t.Helper() + buffer := &bytes.Buffer{} + if err := binary.Write(buffer, binary.LittleEndian, payload); err != nil { + t.Fatalf("encode payload: %v", err) + } + return buffer.Bytes() +} + +func TestHandleLegacyInput(t *testing.T) { + inputs := map[string]any{ + "move": PayloadMove{PayloadHeader: PayloadHeader{Event: OP_MOVE, Length: 4}, X: 10, Y: 10}, + "key down": PayloadKey{PayloadHeader: PayloadHeader{Event: OP_KEY_DOWN, Length: 8}, Key: 0x61}, + "button down": PayloadKey{PayloadHeader: PayloadHeader{Event: OP_KEY_DOWN, Length: 8}, Key: 1}, + } + + for _, canHost := range []bool{false, true} { + for name, input := range inputs { + t.Run(name, func(t *testing.T) { + sessions := session.New(&config.Session{ImplicitHosting: true}) + sess, _, err := sessions.Create("member", types.MemberProfile{CanHost: canHost}) + if err != nil { + t.Fatalf("create session: %v", err) + } + desktop := &recordingDesktop{} + manager := &WebRTCManagerCtx{desktop: desktop} + + if err := manager.handleLegacy(zerolog.Nop(), legacyPayload(t, input), sess); err != nil { + t.Fatalf("handleLegacy: %v", err) + } + + if canHost && len(desktop.calls) != 1 { + t.Errorf("can host: desktop calls = %v, want one", desktop.calls) + } + if !canHost && len(desktop.calls) != 0 { + t.Errorf("cannot host: desktop calls = %v, want none", desktop.calls) + } + }) + } + } +} diff --git a/server/internal/websocket/handler/clipboard.go b/server/internal/websocket/handler/clipboard.go index b1ba20117..e5e3fe40c 100644 --- a/server/internal/websocket/handler/clipboard.go +++ b/server/internal/websocket/handler/clipboard.go @@ -1,17 +1,16 @@ package handler import ( - // "errors" + "errors" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/types/message" ) func (h *MessageHandlerCtx) clipboardSet(session types.Session, payload *message.ClipboardData) error { - // Disabled: profile check - // if !session.Profile().CanAccessClipboard { - // return errors.New("cannot access clipboard") - // } + if !session.Profile().CanHost || !session.Profile().CanAccessClipboard { + return errors.New("cannot access clipboard") + } // Disabled: is host check // if !session.IsHost() { diff --git a/server/internal/websocket/handler/control.go b/server/internal/websocket/handler/control.go index f728805a7..51205ba59 100644 --- a/server/internal/websocket/handler/control.go +++ b/server/internal/websocket/handler/control.go @@ -17,10 +17,9 @@ var ( ) func (h *MessageHandlerCtx) controlRelease(session types.Session) error { - // Disabled: profile check - // if !session.Profile().CanHost || session.PrivateModeEnabled() { - // return ErrIsNotAllowedToHost - // } + if !session.Profile().CanHost { + return ErrIsNotAllowedToHost + } // Disabled: is host check // if !session.IsHost() { diff --git a/server/internal/websocket/handler/control_test.go b/server/internal/websocket/handler/control_test.go new file mode 100644 index 000000000..f87ef408d --- /dev/null +++ b/server/internal/websocket/handler/control_test.go @@ -0,0 +1,147 @@ +package handler + +import ( + "errors" + "testing" + + "github.com/m1k1o/neko/server/internal/config" + "github.com/m1k1o/neko/server/internal/session" + "github.com/m1k1o/neko/server/pkg/types" + "github.com/m1k1o/neko/server/pkg/types/message" +) + +// recordingDesktop records the desktop calls a handler makes. Methods a test +// does not expect are left to the embedded nil interface and panic if called. +type recordingDesktop struct { + types.DesktopManager + calls []string +} + +func (d *recordingDesktop) Move(x, y int) { d.calls = append(d.calls, "Move") } +func (d *recordingDesktop) KeyPress(codes ...uint32) error { + d.calls = append(d.calls, "KeyPress") + return nil +} +func (d *recordingDesktop) ResetKeys() { d.calls = append(d.calls, "ResetKeys") } +func (d *recordingDesktop) SetKeyboardMap(types.KeyboardMap) error { + d.calls = append(d.calls, "SetKeyboardMap") + return nil +} +func (d *recordingDesktop) SetKeyboardModifiers(types.KeyboardModifiers) { + d.calls = append(d.calls, "SetKeyboardModifiers") +} +func (d *recordingDesktop) ClipboardSetText(types.ClipboardText) error { + d.calls = append(d.calls, "ClipboardSetText") + return nil +} + +type recordingWebRTC struct { + types.WebRTCManager +} + +func (recordingWebRTC) SetCursorPosition(x, y int) {} + +var ( + hostProfile = types.MemberProfile{CanLogin: true, CanConnect: true, CanWatch: true, CanHost: true, CanAccessClipboard: true} + viewProfile = types.MemberProfile{CanLogin: true, CanConnect: true, CanWatch: true} +) + +func newTestHandler(t *testing.T, profile types.MemberProfile) (*MessageHandlerCtx, *recordingDesktop, types.Session) { + t.Helper() + sessions := session.New(&config.Session{ImplicitHosting: true}) + sess, _, err := sessions.Create("member", profile) + if err != nil { + t.Fatalf("create session: %v", err) + } + desktop := &recordingDesktop{} + return New(sessions, desktop, nil, recordingWebRTC{}), desktop, sess +} + +func TestNonHostSessionsCannotControl(t *testing.T) { + actions := map[string]func(h *MessageHandlerCtx, s types.Session) error{ + "control request": func(h *MessageHandlerCtx, s types.Session) error { return h.controlRequest(s) }, + "control release": func(h *MessageHandlerCtx, s types.Session) error { return h.controlRelease(s) }, + "move": func(h *MessageHandlerCtx, s types.Session) error { + return h.controlMove(s, &message.ControlPos{X: 1, Y: 1}) + }, + "key press": func(h *MessageHandlerCtx, s types.Session) error { + return h.controlKeyPress(s, &message.ControlKey{Keysym: 0x61}) + }, + "clipboard set": func(h *MessageHandlerCtx, s types.Session) error { + return h.clipboardSet(s, &message.ClipboardData{Text: "text"}) + }, + "keyboard map": func(h *MessageHandlerCtx, s types.Session) error { + return h.keyboardMap(s, &message.KeyboardMap{KeyboardMap: types.KeyboardMap{Layout: "de"}}) + }, + "keyboard modifiers": func(h *MessageHandlerCtx, s types.Session) error { + capsLock := true + return h.keyboardModifiers(s, &message.KeyboardModifiers{KeyboardModifiers: types.KeyboardModifiers{CapsLock: &capsLock}}) + }, + } + + for name, action := range actions { + t.Run(name, func(t *testing.T) { + h, desktop, sess := newTestHandler(t, viewProfile) + if err := action(h, sess); err == nil { + t.Fatal("expected the action to be refused") + } + if len(desktop.calls) != 0 { + t.Errorf("desktop calls = %v, want none", desktop.calls) + } + if sess.IsHost() { + t.Error("session became host") + } + }) + } +} + +func TestHostSessionsCanControl(t *testing.T) { + tests := []struct { + name string + action func(h *MessageHandlerCtx, s types.Session) error + want string + }{ + {"move", func(h *MessageHandlerCtx, s types.Session) error { + return h.controlMove(s, &message.ControlPos{X: 1, Y: 1}) + }, "Move"}, + {"key press", func(h *MessageHandlerCtx, s types.Session) error { + return h.controlKeyPress(s, &message.ControlKey{Keysym: 0x61}) + }, "KeyPress"}, + {"clipboard set", func(h *MessageHandlerCtx, s types.Session) error { + return h.clipboardSet(s, &message.ClipboardData{Text: "text"}) + }, "ClipboardSetText"}, + {"keyboard map", func(h *MessageHandlerCtx, s types.Session) error { + return h.keyboardMap(s, &message.KeyboardMap{KeyboardMap: types.KeyboardMap{Layout: "de"}}) + }, "SetKeyboardMap"}, + {"keyboard modifiers", func(h *MessageHandlerCtx, s types.Session) error { + capsLock := true + return h.keyboardModifiers(s, &message.KeyboardModifiers{KeyboardModifiers: types.KeyboardModifiers{CapsLock: &capsLock}}) + }, "SetKeyboardModifiers"}, + {"control release", func(h *MessageHandlerCtx, s types.Session) error { return h.controlRelease(s) }, "ResetKeys"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, desktop, sess := newTestHandler(t, hostProfile) + if err := tt.action(h, sess); err != nil { + t.Fatalf("action failed: %v", err) + } + if len(desktop.calls) == 0 || desktop.calls[len(desktop.calls)-1] != tt.want { + t.Errorf("desktop calls = %v, want last call %s", desktop.calls, tt.want) + } + }) + } +} + +func TestControlRequestSetsHostForHostProfile(t *testing.T) { + h, _, sess := newTestHandler(t, hostProfile) + if err := h.controlRequest(sess); err != nil { + t.Fatalf("control request: %v", err) + } + if !sess.IsHost() { + t.Error("session is not host") + } + if err := h.controlRequest(sess); !errors.Is(err, ErrIsAlreadyTheHost) { + t.Errorf("second request err = %v, want %v", err, ErrIsAlreadyTheHost) + } +} diff --git a/server/internal/websocket/handler/keyboard.go b/server/internal/websocket/handler/keyboard.go index bbe2d778c..ddf008ad5 100644 --- a/server/internal/websocket/handler/keyboard.go +++ b/server/internal/websocket/handler/keyboard.go @@ -1,13 +1,17 @@ package handler import ( - // "errors" + "errors" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/types/message" ) func (h *MessageHandlerCtx) keyboardMap(session types.Session, payload *message.KeyboardMap) error { + if !session.Profile().CanHost { + return errors.New("is not allowed to host") + } + // Disabled: is host check // if !session.IsHost() { // return errors.New("is not the host") @@ -17,6 +21,10 @@ func (h *MessageHandlerCtx) keyboardMap(session types.Session, payload *message. } func (h *MessageHandlerCtx) keyboardModifiers(session types.Session, payload *message.KeyboardModifiers) error { + if !session.Profile().CanHost { + return errors.New("is not allowed to host") + } + // Disabled: is host check // if !session.IsHost() { // return errors.New("is not the host")