diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 56faffc..4f99a29 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -1,9 +1,11 @@ package browsergateway import ( + "crypto/subtle" "errors" "net" "net/http" + "strings" "sync" ) @@ -14,26 +16,25 @@ const forwardBufSize = 64 * 1024 // ListenPort is the port the browser gateway HTTP server listens on inside the // WireGuard netstack. This is a fixed value shared between newt and pangolin. -const ListenPort = 8082 - -// HardcodedAuthToken is a temporary shared secret used during development. -// TODO: replace with a per-session token negotiated with pangolin. -const HardcodedAuthToken = "pangolin-browser-gateway-dev" +// Targets do not overlap with this port because they start at 40000. +const ListenPort = 39999 // Target represents an allowed proxy destination for the browser gateway. -// Only connections whose (Type, Destination, DestinationPort) match a +// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a // registered Target will be forwarded; all others are rejected. type Target struct { ID int Type string // "rdp" | "ssh" | "vnc" Destination string DestinationPort int + AuthToken string // per-target secret; must match the token supplied by the client } // Config holds the configuration for a Gateway. type Config struct { - // AuthToken is the shared secret required by RDP clients in the RDCleanPath - // ProxyAuth field, and by SSH clients as the authToken query parameter. + // AuthToken is used only for NativeSSH mode (which has no external target + // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are + // stored per-Target and validated by isAllowed. AuthToken string // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead // of proxying to an external SSH server. @@ -86,14 +87,15 @@ func (g *Gateway) RemoveTarget(id int) { delete(g.targets, id) } -// isAllowed reports whether a connection to (targetType, host, port) is -// permitted by the current target list. -func (g *Gateway) isAllowed(targetType, host string, port int) bool { +// isAllowed reports whether a connection to (targetType, host, port) with the +// given authToken is permitted. The token is compared against the per-target +// AuthToken using constant-time comparison to prevent timing attacks. +func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool { g.mu.RLock() defer g.mu.RUnlock() for _, t := range g.targets { if t.Type == targetType && t.Destination == host && t.DestinationPort == port { - return true + return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 } } return false @@ -106,7 +108,11 @@ func (g *Gateway) Start(ln net.Listener) error { g.RegisterHandlers(mux) g.server = &http.Server{Handler: mux} err := g.server.Serve(ln) - if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { + if err == nil || + errors.Is(err, net.ErrClosed) || + errors.Is(err, http.ErrServerClosed) || + strings.Contains(err.Error(), "use of closed") || + strings.Contains(err.Error(), "invalid state") { return nil } return err @@ -114,7 +120,7 @@ func (g *Gateway) Start(ln net.Listener) error { // RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { - mux.HandleFunc("/rdp", g.HandleRDP) - mux.HandleFunc("/ssh", g.HandleSSH) - mux.HandleFunc("/vnc", g.handleVNC) + mux.HandleFunc("/gateway/rdp", g.HandleRDP) + mux.HandleFunc("/gateway/ssh", g.HandleSSH) + mux.HandleFunc("/gateway/vnc", g.handleVNC) } diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index 78046b3..1df5a13 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "crypto/tls" "encoding/binary" "errors" @@ -58,22 +57,18 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { return errors.New("RDCleanPath missing X224 connection PDU") } - // Constant-time comparison to avoid leaking the expected token via timing. - if subtle.ConstantTimeCompare([]byte(pdu.ProxyAuth), []byte(g.authToken)) != 1 { - return errors.New("RDCleanPath ProxyAuth token mismatch") - } - target := pdu.Destination // Default port for RDP if not specified. if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { target = net.JoinHostPort(target, "3389") } - // Validate destination against the registered target allowlist. + // Validate destination against the registered target allowlist, + // including per-target auth token. rdpHost, rdpPortStr, _ := net.SplitHostPort(target) rdpPort, _ := strconv.Atoi(rdpPortStr) - if !g.isAllowed("rdp", rdpHost, rdpPort) { - return fmt.Errorf("RDP destination %s is not in the allowed target list", target) + if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) { + return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target) } log.Printf("Connecting to RDP server %s", target) diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 0596f99..f8e83d4 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -37,12 +37,7 @@ type sshServerMsg struct { func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // -- Validate auth token from query parameter before upgrading -- token := r.URL.Query().Get("authToken") - if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } // In proxy mode we also need host + username from query params. var target, username string @@ -58,11 +53,17 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { port = "22" } sshPort, _ := strconv.Atoi(port) - if !g.isAllowed("ssh", host, sshPort) { - http.Error(w, "destination not allowed", http.StatusForbidden) + if !g.isAllowed("ssh", host, sshPort, token) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) return } target = net.JoinHostPort(host, port) + } else { + // Native SSH mode: validate against the global gateway token. + if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 55ec439..5d917a4 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "io" "log" "net" @@ -28,10 +27,6 @@ const ( // host – VNC backend hostname or IP // port – VNC backend port (default: 5900) func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { - if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("authToken")), []byte(g.authToken)) != 1 { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } host := r.URL.Query().Get("host") port := r.URL.Query().Get("port") if host == "" { @@ -42,8 +37,9 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { port = "5900" } vncPort, _ := strconv.Atoi(port) - if !g.isAllowed("vnc", host, vncPort) { - http.Error(w, "destination not allowed", http.StatusForbidden) + authToken := r.URL.Query().Get("authToken") + if !g.isAllowed("vnc", host, vncPort, authToken) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) return } target := net.JoinHostPort(host, port) diff --git a/main.go b/main.go index c86a35c..566c105 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ type BrowserGatewayTarget struct { Type string `json:"type"` Destination string `json:"destination"` DestinationPort int `json:"destinationPort"` + AuthToken string `json:"authToken"` } type WgData struct { @@ -981,12 +982,11 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( Type: t.Type, Destination: t.Destination, DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, }) } - browserGateway = browsergateway.New(browsergateway.Config{ - AuthToken: browsergateway.HardcodedAuthToken, - }) + browserGateway = browsergateway.New(browsergateway.Config{}) browserGateway.SetTargets(bgTargets) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort})