Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ require (
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/indece-official/go-ebcdic v1.2.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
Expand Down
37 changes: 19 additions & 18 deletions packages/gateway-v2/discovery_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ const (
maxSweepTimeout = 30 * time.Second
)

type discoveryTarget struct {
type rpcTarget struct {
host string
port int
}

type discoveryTargetContextKey struct{}
type rpcTargetContextKey struct{}

// serveDiscoveryOverTLS serves the platform discovery RPCs (ssh-exec + port sweep) over one HTTP mux,
// mirroring the ADCS/PKCS11 handlers. The exec target host/port come from the signed gateway certificate.
// serveDiscoveryOverTLS serves the platform RPCs (ssh-exec, port sweep, connection test) over one HTTP mux,
// mirroring the ADCS/PKCS11 handlers. The target host/port come from the signed gateway certificate.
func serveDiscoveryOverTLS(ctx context.Context, conn *tls.Conn, reader *bufio.Reader, forwardConfig *ForwardConfig) error {
reqCh := make(chan *http.Request, 1)
errCh := make(chan error, 1)
Expand All @@ -55,7 +55,7 @@ func serveDiscoveryOverTLS(ctx context.Context, conn *tls.Conn, reader *bufio.Re

opCtx, cancel := context.WithTimeout(ctx, discoveryRequestDeadline)
defer cancel()
opCtx = context.WithValue(opCtx, discoveryTargetContextKey{}, discoveryTarget{forwardConfig.TargetHost, forwardConfig.TargetPort})
opCtx = context.WithValue(opCtx, rpcTargetContextKey{}, rpcTarget{forwardConfig.TargetHost, forwardConfig.TargetPort})
req = req.WithContext(opCtx)

rw := newBufferedResponseWriter()
Expand All @@ -71,51 +71,52 @@ var discoveryMux = sync.OnceValue(func() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/v1/exec", handleDiscoveryExec)
mux.HandleFunc("/v1/sweep", handleDiscoverySweep)
mux.HandleFunc("/v1/test-connection", handleTestConnection)
return mux
})

func handleDiscoveryExec(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeDiscoveryError(w, http.StatusMethodNotAllowed, "Only POST is supported")
writeRPCError(w, http.StatusMethodNotAllowed, "Only POST is supported")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxSshExecRequestBytes))
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "failed to read request body")
writeRPCError(w, http.StatusBadRequest, "failed to read request body")
return
}
var env sshExecEnvelope
if err := json.Unmarshal(body, &env); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
writeRPCError(w, http.StatusBadRequest, "Invalid request body")
return
}
target, _ := r.Context().Value(discoveryTargetContextKey{}).(discoveryTarget)
target, _ := r.Context().Value(rpcTargetContextKey{}).(rpcTarget)
result, execErr := doSSHExec(target.host, target.port, env)
if execErr != nil {
writeDiscoveryError(w, http.StatusBadGateway, execErr.Error())
writeRPCError(w, http.StatusBadGateway, execErr.Error())
return
}
writeDiscoveryJSON(w, http.StatusOK, sshExecResponse{Result: result})
writeRPCJSON(w, http.StatusOK, sshExecResponse{Result: result})
}

func handleDiscoverySweep(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeDiscoveryError(w, http.StatusMethodNotAllowed, "Only POST is supported")
writeRPCError(w, http.StatusMethodNotAllowed, "Only POST is supported")
return
}
var req struct {
Targets []string `json:"targets"`
TimeoutMs int `json:"timeoutMs"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, maxSweepRequestBytes)).Decode(&req); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
writeRPCError(w, http.StatusBadRequest, "Invalid request body")
return
}
if len(req.Targets) > maxSweepTargets {
writeDiscoveryError(w, http.StatusBadRequest, fmt.Sprintf("target count %d exceeds limit %d", len(req.Targets), maxSweepTargets))
writeRPCError(w, http.StatusBadRequest, fmt.Sprintf("target count %d exceeds limit %d", len(req.Targets), maxSweepTargets))
return
}
writeDiscoveryJSON(w, http.StatusOK, map[string][]string{"open": sweepReachable(r.Context(), req.Targets, req.TimeoutMs)})
writeRPCJSON(w, http.StatusOK, map[string][]string{"open": sweepReachable(r.Context(), req.Targets, req.TimeoutMs)})
}

// sweepReachable TCP-probes each host:port concurrently in-network and returns the reachable ones
Expand Down Expand Up @@ -154,7 +155,7 @@ func sweepReachable(ctx context.Context, targets []string, timeoutMs int) []stri
return open
}

func writeDiscoveryJSON(w http.ResponseWriter, status int, payload any) {
func writeRPCJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "failed to marshal response", http.StatusInternalServerError)
Expand All @@ -165,6 +166,6 @@ func writeDiscoveryJSON(w http.ResponseWriter, status int, payload any) {
_, _ = w.Write(body)
}

func writeDiscoveryError(w http.ResponseWriter, status int, message string) {
writeDiscoveryJSON(w, status, sshExecErrorResponse{Error: sshExecErrorBody{Message: message}})
func writeRPCError(w http.ResponseWriter, status int, message string) {
writeRPCJSON(w, status, sshExecErrorResponse{Error: sshExecErrorBody{Message: message}})
}
4 changes: 3 additions & 1 deletion packages/gateway-v2/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,9 @@ func (g *Gateway) reapIdleSessions() {

func (g *Gateway) registerHeartBeat(ctx context.Context, errCh chan error) {
sendHeartbeat := func() error {
capabilities := map[string]any{}
capabilities := map[string]any{
CapabilityConnectionTest: true,
}
if g.pkcs11Module != nil {
capabilities[CapabilityPkcs11] = true
}
Expand Down
175 changes: 175 additions & 0 deletions packages/gateway-v2/test_connection_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package gatewayv2

import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"time"

"github.com/jackc/pgx/v5"
)

const (
maxTestConnRequestBytes = 64 * 1024
testConnDefaultTimeout = 15 * time.Second
testConnMaxTimeout = 60 * time.Second
)

const (
testConnModePostgres = "postgres"
testConnModeSSH = "ssh"
testConnModeTCP = "tcp"
)

const CapabilityConnectionTest = "connectionTest"

// testConnectionEnvelope carries the fields common to every connection test; mode selects which client validates
// it and which per-mode params struct the body is decoded into. The target host/port come from the signed cert.
type testConnectionEnvelope struct {
Comment thread
x032205 marked this conversation as resolved.
Mode string `json:"mode"` // "postgres" | "ssh" | "tcp"
TimeoutMs int `json:"timeoutMs"`
}

type postgresTestParams struct {
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
SslEnabled bool `json:"sslEnabled"`
SslRejectUnauthorized *bool `json:"sslRejectUnauthorized"`
SslCertificate string `json:"sslCertificate"`
}

type sshTestParams struct {
AuthMethod string `json:"authMethod"`
Username string `json:"username"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
Certificate string `json:"certificate"`
}

type testConnectionResult struct {
Ok bool `json:"ok"`
}

type testConnectionResponse struct {
Result testConnectionResult `json:"result"`
}

// doPostgresConnectionTest authenticates against the target Postgres and runs a trivial query.
func doPostgresConnectionTest(ctx context.Context, host string, port int, params postgresTestParams) error {
config, err := pgx.ParseConfig("")
if err != nil {
return fmt.Errorf("failed to build connection config: %w", err)
}
config.Host = host
Comment thread
x032205 marked this conversation as resolved.
config.Fallbacks = nil
config.Port = uint16(port)
config.User = params.Username
config.Password = params.Password
config.Database = params.Database

if params.SslEnabled {
rejectUnauthorized := params.SslRejectUnauthorized == nil || *params.SslRejectUnauthorized
tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: !rejectUnauthorized}
if params.SslCertificate != "" {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(params.SslCertificate)) {
return fmt.Errorf("failed to parse SSL certificate")
}
tlsConfig.RootCAs = pool
}
config.TLSConfig = tlsConfig
} else {
config.TLSConfig = nil
}

conn, err := pgx.ConnectConfig(ctx, config)
if err != nil {
return err
}
defer conn.Close(ctx)

var result int
return conn.QueryRow(ctx, "SELECT 1").Scan(&result)
}

// doTCPReachabilityTest confirms the target host:port accepts a TCP connection. It's the fallback for targets we
// can't authenticate at rest (RDP, SSH certificate auth), so at least a bad host/port is rejected.
func doTCPReachabilityTest(ctx context.Context, host string, port int) error {
dialer := net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return err
}
return conn.Close()
Comment thread
x032205 marked this conversation as resolved.
}

func handleTestConnection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeRPCError(w, http.StatusMethodNotAllowed, "Only POST is supported")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxTestConnRequestBytes))
if err != nil {
writeRPCError(w, http.StatusBadRequest, "failed to read request body")
return
}
var env testConnectionEnvelope
if err := json.Unmarshal(body, &env); err != nil {
writeRPCError(w, http.StatusBadRequest, "Invalid request body")
return
}

timeout := testConnDefaultTimeout
if env.TimeoutMs > 0 {
if timeout = time.Duration(env.TimeoutMs) * time.Millisecond; timeout > testConnMaxTimeout {
timeout = testConnMaxTimeout
}
}
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()

target, _ := r.Context().Value(rpcTargetContextKey{}).(rpcTarget)

var testErr error
switch env.Mode {
case testConnModePostgres:
var params postgresTestParams
if err := json.Unmarshal(body, &params); err != nil {
writeRPCError(w, http.StatusBadRequest, "Invalid request body")
return
}
testErr = doPostgresConnectionTest(ctx, target.host, target.port, params)
case testConnModeSSH:
var params sshTestParams
if err := json.Unmarshal(body, &params); err != nil {
writeRPCError(w, http.StatusBadRequest, "Invalid request body")
return
}
_, testErr = doSSHExec(target.host, target.port, sshExecEnvelope{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: SSH password can be sent to an unverified server

This passes connection-test credentials to doSSHExec, which uses ssh.InsecureIgnoreHostKey(). An on-path attacker can impersonate the certificate-bound target and receive the supplied password; require the platform to provide a trusted host key or host CA and verify it before authenticating.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way we do test is consistent with discovery. I don't think the attack surface is large especially since gateways are usually within regulated private networks.

Command: "true",
AuthMethod: params.AuthMethod,
Username: params.Username,
Password: params.Password,
PrivateKey: params.PrivateKey,
Certificate: params.Certificate,
TimeoutMs: env.TimeoutMs,
})
case testConnModeTCP:
testErr = doTCPReachabilityTest(ctx, target.host, target.port)
default:
writeRPCError(w, http.StatusBadRequest, fmt.Sprintf("unsupported test-connection mode: %q", env.Mode))
return
}

if testErr != nil {
writeRPCError(w, http.StatusBadGateway, testErr.Error())
Comment thread
x032205 marked this conversation as resolved.
return
}
writeRPCJSON(w, http.StatusOK, testConnectionResponse{Result: testConnectionResult{Ok: true}})
}
Loading