From a749827ae00be652e652e92d6fdd8030516ea762 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:46:17 +0200 Subject: [PATCH 1/2] fix(signal): restore Web Remote after enrollment outbound gate (Refs #313) Accept PunchHole/RequestRelay from PANEL_SIGNAL_PROXY_CIDRS (default loopback) so panel-proxied Web Remote works again without weakening #302. --- CHANGELOG.md | 3 + betterdesk-server/config/config.go | 22 +++++++ betterdesk-server/config/proxy_trust.go | 14 ++++ betterdesk-server/config/proxy_trust_test.go | 31 +++++++++ betterdesk-server/signal/handler.go | 12 +++- betterdesk-server/signal/handler_test.go | 69 ++++++++++++++++++++ docs/important/betterdesk-enrollment.md | 1 + web-nodejs/.env.example | 7 ++ 8 files changed, 158 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c12feb..e0fae504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +### Fixed +- **Web Remote broken after enrollment outbound gate (#313, #302):** PunchHole/RequestRelay from the panel `/ws/rendezvous` proxy (default loopback CIDRs via `PANEL_SIGNAL_PROXY_CIDRS`) are accepted again without requiring a registered RustDesk peer. Unapproved clients and anonymous public initiators remain blocked. Ships via panel update (Go signal restart). Native/all-in-one needs no env change; Docker split console↔server must set `PANEL_SIGNAL_PROXY_CIDRS` to the console CIDR if still denied. + ### Changed - _(none yet)_ diff --git a/betterdesk-server/config/config.go b/betterdesk-server/config/config.go index ef49c5eb..9dafd7bf 100644 --- a/betterdesk-server/config/config.go +++ b/betterdesk-server/config/config.go @@ -75,6 +75,11 @@ type Config struct { // X-Forwarded-For / X-Real-IP. Required when TrustProxy is true — empty // means forwarded headers are ignored (security-first, issue #276). TrustedProxies []*net.IPNet + // PanelSignalProxyCIDRs is the allowlist of source IPs for the Node panel + // WebSocket→TCP proxy (/ws/rendezvous → hbbs). Web Remote never registers + // as a RustDesk peer; PunchHole/RequestRelay from these CIDRs are treated + // as panel-authorized initiators (#302 regression fix). Default: loopback. + PanelSignalProxyCIDRs []*net.IPNet RelayMaxConnsIP int // Max relay connections per IP (0 = unlimited) InitAdminUser string // Initial admin username (created on first start) InitAdminPass string // Initial admin password (auto-generated if empty) @@ -152,8 +157,13 @@ type Config struct { BillingRequireWorkReport bool // Require technician report before session close } +// DefaultPanelSignalProxyCIDRs is the loopback allowlist for the panel→hbbs +// TCP proxy used by Web Remote (all-in-one and same-host native installs). +const DefaultPanelSignalProxyCIDRs = "127.0.0.0/8,::1/128" + // DefaultConfig returns a Config with sensible defaults. func DefaultConfig() *Config { + panelCIDRs, _ := ParseTrustedProxies(DefaultPanelSignalProxyCIDRs) return &Config{ SignalPort: 21116, RelayPort: 21117, @@ -167,6 +177,7 @@ func DefaultConfig() *Config { ClientSessionMaxDays: 30, RelayMaxConnsIP: 20, EnrollmentMode: EnrollmentModeOpen, // Backward compatible default + PanelSignalProxyCIDRs: panelCIDRs, CDAPPort: 21122, CDAPEnabled: true, // Enabled by default; set CDAP_ENABLED=N for minimal installs CDAPRateLimit: 30, @@ -315,6 +326,17 @@ func (c *Config) LoadEnv() { c.TrustedProxies = nets } } + // Panel Web Remote proxy CIDRs (#302 regression). Unset keeps DefaultConfig + // loopback allowlist; set to override (e.g. Docker bridge when panel and Go + // run in separate containers). + if v := os.Getenv("PANEL_SIGNAL_PROXY_CIDRS"); v != "" { + nets, err := ParseTrustedProxies(v) + if err != nil { + log.Printf("[config] PANEL_SIGNAL_PROXY_CIDRS parse error: %v — keeping previous allowlist", err) + } else { + c.PanelSignalProxyCIDRs = nets + } + } if v := os.Getenv("RELAY_MAX_CONNS_PER_IP"); v != "" { if n, err := strconv.Atoi(v); err == nil { c.RelayMaxConnsIP = n diff --git a/betterdesk-server/config/proxy_trust.go b/betterdesk-server/config/proxy_trust.go index ce48b8aa..049d27fe 100644 --- a/betterdesk-server/config/proxy_trust.go +++ b/betterdesk-server/config/proxy_trust.go @@ -64,6 +64,20 @@ func (c *Config) RemoteAddrIsTrustedProxy(remoteAddr string) bool { return false } +// IPIsPanelSignalProxy reports whether ip is in PanelSignalProxyCIDRs +// (Node panel → hbbs TCP proxy for Web Remote). Empty allowlist → false. +func (c *Config) IPIsPanelSignalProxy(ip net.IP) bool { + if c == nil || ip == nil || len(c.PanelSignalProxyCIDRs) == 0 { + return false + } + for _, n := range c.PanelSignalProxyCIDRs { + if n != nil && n.Contains(ip) { + return true + } + } + return false +} + // ShouldHonorForwardedHeaders is true only when TrustProxy is set and the // direct connection comes from a configured trusted proxy CIDR. func (c *Config) ShouldHonorForwardedHeaders(remoteAddr string) bool { diff --git a/betterdesk-server/config/proxy_trust_test.go b/betterdesk-server/config/proxy_trust_test.go index db2c77c3..40dd77c4 100644 --- a/betterdesk-server/config/proxy_trust_test.go +++ b/betterdesk-server/config/proxy_trust_test.go @@ -54,6 +54,37 @@ func TestShouldHonorForwardedHeaders(t *testing.T) { } } +func TestIPIsPanelSignalProxy(t *testing.T) { + t.Parallel() + cfg := DefaultConfig() + if !cfg.IPIsPanelSignalProxy(net.ParseIP("127.0.0.1")) { + t.Fatal("127.0.0.1 should match default loopback allowlist") + } + if !cfg.IPIsPanelSignalProxy(net.ParseIP("::1")) { + t.Fatal("::1 should match default loopback allowlist") + } + if cfg.IPIsPanelSignalProxy(net.ParseIP("198.51.100.1")) { + t.Fatal("public IP must not match default panel proxy allowlist") + } + + cfg.PanelSignalProxyCIDRs = nil + if cfg.IPIsPanelSignalProxy(net.ParseIP("127.0.0.1")) { + t.Fatal("empty allowlist must reject") + } + + nets, err := ParseTrustedProxies("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + cfg.PanelSignalProxyCIDRs = nets + if !cfg.IPIsPanelSignalProxy(net.ParseIP("10.1.2.3")) { + t.Fatal("10.1.2.3 should match 10.0.0.0/8") + } + if cfg.IPIsPanelSignalProxy(net.ParseIP("127.0.0.1")) { + t.Fatal("loopback should not match custom 10.0.0.0/8-only allowlist") + } +} + func mustParseCIDR(t *testing.T, cidr string) *net.IPNet { t.Helper() _, n, err := net.ParseCIDR(cidr) diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index a9238a37..7b719427 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -29,6 +29,10 @@ const refuseRelayProtocolMismatch = "Protocol mismatch: WebSocket and native TCP // a peer that is not registered (or not enrollment-approved in managed/locked). const refuseInitiatorNotAuthorized = "Not authorized" +// panelWebRemoteInitiatorID is the synthetic initiator id logged when PunchHole/ +// RequestRelay arrives from the Node panel WebSocket→TCP proxy (#302 Web Remote). +const panelWebRemoteInitiatorID = "panel-web-remote" + // relayTransportMismatch reports whether initiator and target use incompatible // relay transports (WebSocket Mode vs native TCP/UDP). Signaling may still be // mixed; this gate only covers the typical case where ConnType reflects the @@ -1711,11 +1715,14 @@ func (s *Server) relayUnauthorizedResponse(relayServer string) *pb.RendezvousMes } // requireAuthorizedInitiator enforces that PunchHole/RequestRelay may only be -// started by a live registered peer (#302). +// started by a live registered peer (#302), or by the Node panel Web Remote +// proxy (trusted PANEL_SIGNAL_PROXY_CIDRS — typically loopback). // // All enrollment modes require the initiator to be present in the in-memory // peer map (closes anonymous rendezvous). Managed and locked modes additionally // require an approved DB peer row (pending enrollment alone is not enough). +// Panel proxy initiators skip the peer-map / DB checks: operator auth is +// enforced at the panel WS upgrade before TCP is bridged to hbbs. func (s *Server) requireAuthorizedInitiator(raddr *net.UDPAddr, targetID string) (string, bool) { if raddr == nil { return "", false @@ -1723,6 +1730,9 @@ func (s *Server) requireAuthorizedInitiator(raddr *net.UDPAddr, targetID string) initiator := s.peers.FindByIP(raddr.IP) if initiator == nil || initiator.IsExpired(config.RegTimeout) { + if s.cfg != nil && s.cfg.IPIsPanelSignalProxy(raddr.IP) { + return panelWebRemoteInitiatorID, true + } s.logUnauthorizedInitiator(raddr, "", targetID, "initiator_not_registered") return "", false } diff --git a/betterdesk-server/signal/handler_test.go b/betterdesk-server/signal/handler_test.go index ef35ef18..1cd60476 100644 --- a/betterdesk-server/signal/handler_test.go +++ b/betterdesk-server/signal/handler_test.go @@ -892,3 +892,72 @@ func TestOpenRegisteredInitiatorCanRequestRelay(t *testing.T) { t.Fatalf("uuid = %q", rr.Uuid) } } + +func TestPanelProxyLoopbackCanPunchHoleWithoutPeer(t *testing.T) { + srv, _ := newTestSignalServer(t, config.EnrollmentModeManaged) + putOnlinePeer(srv, "TGTWEB1", "203.0.113.90", 52000, peer.ConnTCP) + + id, ok := srv.requireAuthorizedInitiator(udpAddr("127.0.0.1", 51000), "TGTWEB1") + if !ok || id != panelWebRemoteInitiatorID { + t.Fatalf("loopback panel proxy = (%q, %v), want (%q, true)", id, ok, panelWebRemoteInitiatorID) + } + + // Web Remote: panel bridges from loopback; no RegisterPeer for the browser. + // P2P-first may return nil while forwarding to the target; unauthorized always + // returns PunchHoleResponse{Failure: ID_NOT_EXIST}. + resp := srv.handlePunchHoleRequestTCP(&pb.PunchHoleRequest{Id: "TGTWEB1"}, udpAddr("127.0.0.1", 51000)) + if phr := resp.GetPunchHoleResponse(); phr != nil && phr.Failure == pb.PunchHoleResponse_ID_NOT_EXIST { + t.Fatal("panel loopback PunchHole must not be refused as unauthorized") + } +} + +func TestPanelProxyLoopbackCanRequestRelayWithoutPeer(t *testing.T) { + srv, _ := newTestSignalServer(t, config.EnrollmentModeManaged) + putOnlinePeer(srv, "TGTWEB2", "203.0.113.91", 52000, peer.ConnTCP) + + resp := srv.handleRequestRelayTCP(&pb.RequestRelay{ + Id: "TGTWEB2", + Uuid: "web-remote-relay-uuid", + }, udpAddr("127.0.0.1", 51000), peer.ConnTCP) + rr := resp.GetRelayResponse() + if rr == nil { + t.Fatalf("expected RelayResponse, got %+v", resp) + } + if rr.RefuseReason != "" { + t.Fatalf("panel loopback relay refused: %q", rr.RefuseReason) + } + if rr.Uuid != "web-remote-relay-uuid" { + t.Fatalf("uuid = %q", rr.Uuid) + } +} + +func TestPublicAnonymousInitiatorStillRejectedWithPanelAllowlist(t *testing.T) { + srv, _ := newTestSignalServer(t, config.EnrollmentModeOpen) + putOnlinePeer(srv, "TGTPUB1", "203.0.113.92", 52000, peer.ConnTCP) + + id, ok := srv.requireAuthorizedInitiator(udpAddr("198.51.100.99", 51000), "TGTPUB1") + if ok || id != "" { + t.Fatalf("public anonymous = (%q, %v), want reject", id, ok) + } + + resp := srv.handlePunchHoleRequestTCP(&pb.PunchHoleRequest{Id: "TGTPUB1"}, udpAddr("198.51.100.99", 51000)) + phr := resp.GetPunchHoleResponse() + if phr == nil || phr.Failure != pb.PunchHoleResponse_ID_NOT_EXIST { + t.Fatalf("public anonymous PunchHole should be unauthorized, got %+v", resp) + } +} + +func TestManagedPendingStillRejectedDespitePanelAllowlist(t *testing.T) { + // Pending peer on a non-loopback IP must still be blocked (#302). + srv, database := newTestSignalServer(t, config.EnrollmentModeManaged) + putOnlinePeer(srv, "TGTPEND3", "203.0.113.93", 52000, peer.ConnUDP) + putOnlinePeer(srv, "PENDINIT3", "198.51.100.73", 51000, peer.ConnUDP) + if err := database.SetConfig("pending_device_PENDINIT3", `{"device_id":"PENDINIT3"}`); err != nil { + t.Fatalf("SetConfig: %v", err) + } + + id, ok := srv.requireAuthorizedInitiator(udpAddr("198.51.100.73", 51000), "TGTPEND3") + if ok { + t.Fatalf("pending initiator must be rejected, got id=%q", id) + } +} diff --git a/docs/important/betterdesk-enrollment.md b/docs/important/betterdesk-enrollment.md index 3c5277fc..e48e3655 100644 --- a/docs/important/betterdesk-enrollment.md +++ b/docs/important/betterdesk-enrollment.md @@ -4,4 +4,5 @@ - Outbound session initiation (`PunchHoleRequest` / `RequestRelay`) requires an authorized initiator (#302): - All modes: initiator must be a live registered peer in the signal peer map (anonymous rendezvous is refused). - Managed / locked: initiator must also exist as an approved peer in the DB (`GetPeer`); pending queue alone is not enough. + - **Panel Web Remote exception:** PunchHole/RequestRelay from `PANEL_SIGNAL_PROXY_CIDRS` (default loopback `127.0.0.0/8,::1/128`) are accepted without a peer registration. The Node panel authenticates the operator (or guest) at `/ws/rendezvous` upgrade before TCP-bridging to hbbs. Split panel↔Go installs must set the console container/host CIDR. Synthetic initiator id in audit logs: `panel-web-remote`. - Commit references for GitHub issues should use `Refs #N` (not `Fixes`) when the user wants the issue left open. diff --git a/web-nodejs/.env.example b/web-nodejs/.env.example index 408da50d..a411a4ec 100644 --- a/web-nodejs/.env.example +++ b/web-nodejs/.env.example @@ -90,6 +90,13 @@ TRUST_PROXY=false # Example (same-host Nginx/Caddy): 127.0.0.1/32,::1/128 TRUSTED_PROXIES= +# Source IPs allowed to start PunchHole/RequestRelay without a registered RustDesk peer. +# Used by the Node panel WebSocket→TCP proxy for Web Remote (/ws/rendezvous → hbbs). +# Default in Go is 127.0.0.0/8,::1/128 when unset. Override for split panel↔Go containers +# (e.g. Docker bridge CIDR of the console). Refs #302. +# PANEL_SIGNAL_PROXY_CIDRS=127.0.0.0/8,::1/128 +PANEL_SIGNAL_PROXY_CIDRS= + # WebSocket Origin allow-list (comma-separated). Same-host browser upgrades are always allowed. # WS_ALLOWED_ORIGINS=https://panel.example.com From c27eaf8ed6593837482e648c5ddff645d9c6b02d Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:50:00 +0200 Subject: [PATCH 2/2] fix(web-nodejs): bump brace-expansion override for npm audit CI GHSA-mh99-v99m-4gvg requires brace-expansion >=5.0.8; previous ^1.1.16 still failed Web Console CI audit on stable. --- CHANGELOG.md | 1 + web-nodejs/package-lock.json | 33 ++++++++++++++++----------------- web-nodejs/package.json | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0fae504..9b30bcd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Fixed - **Web Remote broken after enrollment outbound gate (#313, #302):** PunchHole/RequestRelay from the panel `/ws/rendezvous` proxy (default loopback CIDRs via `PANEL_SIGNAL_PROXY_CIDRS`) are accepted again without requiring a registered RustDesk peer. Unapproved clients and anonymous public initiators remain blocked. Ships via panel update (Go signal restart). Native/all-in-one needs no env change; Docker split console↔server must set `PANEL_SIGNAL_PROXY_CIDRS` to the console CIDR if still denied. +- **npm audit (`brace-expansion`):** override bumped to `^5.0.8` (GHSA-mh99-v99m-4gvg) so Web Console CI `npm audit --omit=dev` passes on stable. ### Changed - _(none yet)_ diff --git a/web-nodejs/package-lock.json b/web-nodejs/package-lock.json index 4d261dd1..2e495da2 100644 --- a/web-nodejs/package-lock.json +++ b/web-nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "betterdesk-console", - "version": "3.3.173", + "version": "3.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "betterdesk-console", - "version": "3.3.173", + "version": "3.4.1", "license": "AGPL-3.0", "dependencies": { "axios": "^1.9.0", @@ -1587,10 +1587,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -1695,13 +1698,15 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -2021,12 +2026,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", diff --git a/web-nodejs/package.json b/web-nodejs/package.json index 797cbefd..32f1a36f 100644 --- a/web-nodejs/package.json +++ b/web-nodejs/package.json @@ -54,7 +54,7 @@ "js-yaml": "^3.15.0", "tar": "^7.5.21", "path-to-regexp": "^0.1.13", - "brace-expansion": "^1.1.16", + "brace-expansion": "^5.0.8", "form-data": "^4.0.6", "body-parser": "^1.20.6", "protobufjs": "^7.6.5"