Support per target auth token

This commit is contained in:
Owen
2026-05-15 13:46:39 -07:00
parent 559b7021fe
commit 710408ac67
5 changed files with 40 additions and 42 deletions

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

@@ -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})