Gate hostselection=any to public destinations and a port allow-list (#188)

The `roundrobin`, `signed`, and `unsigned` host-selection modes route
requests against an operator-curated `Server.Hosts` list. The `any`
mode does not -- it forwards to whatever `?host=` value the request
carries, which makes the gateway usable as a generic TCP relay
against whatever the gateway can reach (loopback, RFC1918, link-local,
the cloud metadata service, arbitrary high-numbered ports on public
hosts).

Add a small destination policy applied only in `any` mode:

* Reject hosts that resolve to loopback, RFC1918, IPv6 ULA,
  link-local, unspecified, or multicast addresses. Operators can opt
  back in with `Server.AllowPrivateDestinations: true`.
* Restrict the destination port to `Server.AllowedDestinationPorts`
  (default {3389}).

The other host-selection modes are unaffected -- the operator already
curates their hosts list.

The DestinationPolicy zero value is the secure default, so direct
&Handler{} constructions in tests still get the expected behavior.
DNS names are resolved at validation time and every returned address
is checked.
This commit is contained in:
bolkedebruin
2026-04-30 18:42:24 +02:00
committed by GitHub
parent 49fa170023
commit 449cd1e2fe
8 changed files with 260 additions and 10 deletions

View File

@@ -56,6 +56,14 @@ type ServerConfig struct {
Authentication []string `koanf:"authentication"`
AuthSocket string `koanf:"authsocket"`
BasicAuthTimeout int `koanf:"basicauthtimeout"`
// AllowedDestinationPorts gates the TCP ports `hostselection: any` may
// forward to. Empty defaults to {3389}. Ignored for the curated host
// modes (roundrobin, signed, unsigned).
AllowedDestinationPorts []int `koanf:"alloweddestinationports"`
// AllowPrivateDestinations, when true, lets `hostselection: any`
// forward to loopback, RFC1918, link-local, and IPv6 ULA targets.
// Default false.
AllowPrivateDestinations bool `koanf:"allowprivatedestinations"`
}
type KerberosConfig struct {

View File

@@ -114,10 +114,12 @@ func main() {
NoUsername: conf.Client.NoUsername,
OverridableRdpKeys: conf.Client.RdpOverridableKeys,
},
GatewayAddress: url,
TemplateFile: conf.Client.Defaults,
RdpSigningCert: conf.Client.SigningCert,
RdpSigningKey: conf.Client.SigningKey,
GatewayAddress: url,
TemplateFile: conf.Client.Defaults,
RdpSigningCert: conf.Client.SigningCert,
RdpSigningKey: conf.Client.SigningKey,
AllowedDestinationPorts: conf.Server.AllowedDestinationPorts,
AllowPrivateDestinations: conf.Server.AllowPrivateDestinations,
}
if conf.Caps.TokenAuth {

View File

@@ -12,10 +12,12 @@ import (
"html/template"
"log"
rnd "math/rand"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -42,6 +44,16 @@ type Config struct {
RdpSigningCert string
RdpSigningKey string
TemplatesPath string
// AllowedDestinationPorts gates which TCP ports the `any` host-selection
// mode may forward to. Empty defaults to {3389}. Ignored for
// roundrobin / signed / unsigned where the operator already curates the
// hosts list.
AllowedDestinationPorts []int
// AllowPrivateDestinations, when true, lets `any` mode forward to
// loopback / RFC1918 / link-local / IPv6 ULA destinations. Default
// false: only globally-routable addresses are accepted. Operators that
// genuinely need to reach private destinations from `any` must opt in.
AllowPrivateDestinations bool
}
// WebConfig represents the web interface configuration
@@ -96,6 +108,88 @@ type Handler struct {
templatesPath string
webConfig *WebConfig
htmlTemplate *template.Template
destPolicy destinationPolicy
}
// destinationPolicy gates the host strings accepted in `any` host-selection
// mode. With `signed` / `unsigned` / `roundrobin` the operator curates the
// hosts list; with `any` the value comes from the request, so the gateway
// must ensure it isn't being asked to act as a TCP relay against an
// internal-only target.
type destinationPolicy struct {
allowedPorts map[int]struct{}
allowPrivateDestinations bool
}
func newDestinationPolicy(allowedPorts []int, allowPrivate bool) destinationPolicy {
if len(allowedPorts) == 0 {
allowedPorts = []int{3389}
}
set := make(map[int]struct{}, len(allowedPorts))
for _, p := range allowedPorts {
set[p] = struct{}{}
}
return destinationPolicy{
allowedPorts: set,
allowPrivateDestinations: allowPrivate,
}
}
// allow validates a host:port (or bare host) string against the policy.
// Returns nil if the destination is acceptable, an error otherwise. The
// zero-value policy is treated as the secure default ({3389}, public-only).
func (p destinationPolicy) allow(hostport string) error {
host, port, err := net.SplitHostPort(hostport)
if err != nil {
// no port present -- assume the protocol default
host = hostport
port = "3389"
}
portNum, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("invalid port %q in %q", port, hostport)
}
allowedPorts := p.allowedPorts
if len(allowedPorts) == 0 {
allowedPorts = map[int]struct{}{3389: {}}
}
if _, ok := allowedPorts[portNum]; !ok {
return fmt.Errorf("port %d not in allow-list", portNum)
}
if p.allowPrivateDestinations {
return nil
}
if ip := net.ParseIP(host); ip != nil {
return checkPublicIP(host, ip)
}
addrs, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("cannot resolve %q: %s", host, err)
}
for _, ip := range addrs {
if err := checkPublicIP(host, ip); err != nil {
return err
}
}
return nil
}
func checkPublicIP(host string, ip net.IP) error {
switch {
case ip.IsLoopback():
return fmt.Errorf("destination %q (%s) is loopback", host, ip)
case ip.IsPrivate():
return fmt.Errorf("destination %q (%s) is in a private range", host, ip)
case ip.IsLinkLocalUnicast():
return fmt.Errorf("destination %q (%s) is link-local", host, ip)
case ip.IsUnspecified():
return fmt.Errorf("destination %q (%s) is unspecified", host, ip)
case ip.IsMulticast():
return fmt.Errorf("destination %q (%s) is multicast", host, ip)
}
return nil
}
func (c *Config) NewHandler() *Handler {
@@ -115,6 +209,7 @@ func (c *Config) NewHandler() *Handler {
rdpOpts: c.RdpOpts,
rdpDefaults: c.TemplateFile,
templatesPath: c.TemplatesPath,
destPolicy: newDestinationPolicy(c.AllowedDestinationPorts, c.AllowPrivateDestinations),
}
// set up RDP signer if config values are set
@@ -401,6 +496,10 @@ func (h *Handler) getHost(ctx context.Context, u *url.URL) (string, error) {
if !ok {
return "", errors.New("invalid query parameter")
}
if err := h.destPolicy.allow(hosts[0]); err != nil {
log.Printf("rejecting `any` destination %q: %s", hosts[0], err)
return "", fmt.Errorf("destination not allowed: %s", err)
}
return hosts[0], nil
default:
return h.selectRandomHost(), nil

View File

@@ -341,9 +341,12 @@ func TestHostSelectionIntegration(t *testing.T) {
name: "any host allowed",
hostSelection: "any",
hosts: []string{"host1.com"},
queryParams: "?host=any-host.com",
expectHost: "any-host.com",
expectError: false,
// TEST-NET-3 literal stays in the policy's "publicly
// routable + RDP port" branch, which is the only thing
// `any` mode accepts by default.
queryParams: "?host=203.0.113.5:3389",
expectHost: "203.0.113.5:3389",
expectError: false,
},
}

View File

@@ -84,9 +84,10 @@ func TestGetHost(t *testing.T) {
t.Fatalf("host %s is not equal to input %s", host, hosts[0])
}
// check any
// check any -- TEST-NET-3 literal stays in the policy's "publicly
// routable" branch so this case still exercises the happy path.
c.HostSelection = "any"
test := "bla.bla.com"
test := "203.0.113.5:3389"
vals.Set("host", test)
u.RawQuery = vals.Encode()
h = c.NewHandler()
@@ -119,6 +120,77 @@ func TestGetHost(t *testing.T) {
}
}
// TestGetHostAnyRejectsSensitiveDestinations asserts that with
// HostSelection="any" the gateway refuses hosts that resolve to addresses
// it should not be reachable as: loopback, RFC1918, link-local, the cloud
// metadata service, IPv6 loopback / ULA. Without this check an
// authenticated user can use the gateway as a generic TCP relay against
// any internal target the gateway can reach.
func TestGetHostAnyRejectsSensitiveDestinations(t *testing.T) {
cases := []struct {
name string
host string
}{
{"loopback v4", "127.0.0.1:3389"},
{"loopback name", "localhost:3389"},
{"cloud metadata", "169.254.169.254:80"},
{"rfc1918 10/8", "10.0.0.5:3389"},
{"rfc1918 192.168/16", "192.168.1.10:3389"},
{"rfc1918 172.16/12", "172.16.5.10:3389"},
{"ipv6 loopback", "[::1]:3389"},
{"ipv6 ula", "[fc00::1]:3389"},
{"non-rdp port on public host", "203.0.113.5:6379"},
}
c := Config{
HostSelection: "any",
Hosts: hosts,
}
h := c.NewHandler()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u := &url.URL{Host: "example.com"}
vals := u.Query()
vals.Set("host", tc.host)
u.RawQuery = vals.Encode()
got, err := h.getHost(context.Background(), u)
if err == nil {
t.Errorf("getHost(%q) returned %q with no error; sensitive destinations must be refused in 'any' mode", tc.host, got)
}
})
}
}
// TestGetHostAnyAllowsExplicitOptIn confirms that an operator can re-enable
// access to private destinations and additional ports for `any` mode when
// the deployment legitimately needs it.
func TestGetHostAnyAllowsExplicitOptIn(t *testing.T) {
c := Config{
HostSelection: "any",
Hosts: hosts,
AllowedDestinationPorts: []int{3389, 5985},
AllowPrivateDestinations: true,
}
h := c.NewHandler()
for _, target := range []string{"10.0.0.1:3389", "127.0.0.1:5985"} {
u := &url.URL{Host: "example.com"}
vals := u.Query()
vals.Set("host", target)
u.RawQuery = vals.Encode()
got, err := h.getHost(context.Background(), u)
if err != nil {
t.Errorf("getHost(%q) rejected with %v; explicit opt-in must allow private and extra-port destinations", target, err)
}
if got != target {
t.Errorf("getHost(%q) = %q, want unchanged", target, got)
}
}
}
func TestHandler_HandleDownload(t *testing.T) {
req, err := http.NewRequest("GET", "/connect", nil)
if err != nil {