From b597c35b4774224c8b3aac2bcd2465df0eb59cc9 Mon Sep 17 00:00:00 2001 From: Aniruddha Adak Date: Tue, 22 Sep 2026 09:45:32 +0000 Subject: [PATCH] fix(postgres): require TLS for query_render_postgres connections Render-managed Postgres always requires SSL, but pgx defaults to sslmode=prefer, leaving a plaintext fallback that silently downgrades on any TLS hiccup and surfaces as FATAL: SSL/TLS required. Parse via a helper that upgrades TLS-less configs and drops plaintext fallbacks so IP-allowlisted instances connect like psql. Fixes #6 --- pkg/postgres/tools.go | 46 +++++++++++++++++++++++++++++++++++++- pkg/postgres/tools_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/pkg/postgres/tools.go b/pkg/postgres/tools.go index 8073e51..26ef93c 100644 --- a/pkg/postgres/tools.go +++ b/pkg/postgres/tools.go @@ -2,8 +2,10 @@ package postgres import ( "context" + "crypto/tls" "encoding/json" "fmt" + "net" "github.com/jackc/pgx/v5" "github.com/mark3labs/mcp-go/mcp" @@ -246,7 +248,7 @@ func queryPostgres(postgresRepo *Repo) server.ServerTool { return mcp.NewToolResultError(err.Error()), nil } - config, err := pgx.ParseConfig(connectionInfo.ExternalConnectionString) + config, err := parsePostgresConnConfig(connectionInfo.ExternalConnectionString) if err != nil { return mcp.NewToolResultErrorFromErr("Error parsing connection string", err), nil } @@ -339,3 +341,45 @@ func connectErrorResult(err error, allowList []client.CidrBlockAndDescription) * } return mcp.NewToolResultError(fmt.Sprintf("Error connecting to database: %v\n\n%s", err, ipRestrictedHint)) } + +// parsePostgresConnConfig parses a Render Postgres connection string and +// enforces TLS (sslmode=require semantics). Render-managed Postgres always +// requires SSL, but pgx defaults to sslmode=prefer: the primary config uses +// TLS with a plaintext fallback, and any TLS hiccup silently downgrades to an +// unencrypted attempt that the server rejects with FATAL: SSL/TLS required +// (issue #6). Dropping plaintext fallbacks and upgrading TLS-less configs +// keeps the failure in the TLS layer and lets IP-allowlisted instances +// connect the same way psql does. +func parsePostgresConnConfig(connStr string) (*pgx.ConnConfig, error) { + config, err := pgx.ParseConfig(connStr) + if err != nil { + return nil, err + } + + if config.TLSConfig == nil { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + } + if net.ParseIP(config.Host) == nil && config.Host != "" { + tlsConfig.ServerName = config.Host + } + config.TLSConfig = tlsConfig + } + + // Keep TLS fallbacks (multi-host HA) but drop any plaintext fallback so + // pgx never silently downgrades to an unencrypted connection. + kept := config.Fallbacks[:0] + for _, fb := range config.Fallbacks { + if fb.TLSConfig != nil { + kept = append(kept, fb) + } + } + // Zero the tail so dropped *FallbackConfig pointers don't linger. + for i := len(kept); i < len(config.Fallbacks); i++ { + config.Fallbacks[i] = nil + } + config.Fallbacks = kept + + return config, nil +} diff --git a/pkg/postgres/tools_test.go b/pkg/postgres/tools_test.go index e130178..3c8f3c6 100644 --- a/pkg/postgres/tools_test.go +++ b/pkg/postgres/tools_test.go @@ -223,3 +223,45 @@ func TestCreatePostgresToolPlanEnumIsAccepted(t *testing.T) { assert.True(t, slices.ContainsFunc(plans, specBased.MatchString), "no spec-based plan name advertised, got %v", plans) } + +// TestParsePostgresConnConfigEnforcesTLS locks require-TLS semantics for +// Render-managed Postgres (issue #6). pgx defaults to sslmode=prefer, so a +// connection string without an explicit sslmode parses to a TLS primary plus +// a plaintext fallback; any TLS hiccup then downgrades to the unencrypted +// fallback that Render rejects with FATAL: SSL/TLS required. The helper must +// leave no plaintext path: primary TLS present and every fallback TLS-only. +func TestParsePostgresConnConfigEnforcesTLS(t *testing.T) { + cases := []struct { + name string + connStr string + }{ + { + name: "no sslmode (Render default)", + connStr: "postgres://user:pass@myhost.ohio-postgres.render.com:5432/mydb", + }, + { + name: "explicit prefer", + connStr: "postgres://user:pass@myhost.ohio-postgres.render.com:5432/mydb?sslmode=prefer", + }, + { + name: "explicit require", + connStr: "postgres://user:pass@myhost.ohio-postgres.render.com:5432/mydb?sslmode=require", + }, + { + name: "explicit disable is upgraded", + connStr: "postgres://user:pass@myhost.ohio-postgres.render.com:5432/mydb?sslmode=disable", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := parsePostgresConnConfig(tc.connStr) + require.NoError(t, err) + require.NotNil(t, cfg.TLSConfig, "primary connection must use TLS") + for i, fb := range cfg.Fallbacks { + require.NotNil(t, fb, "fallback %d must not be nil", i) + assert.NotNil(t, fb.TLSConfig, "fallback %d must use TLS, no plaintext downgrade", i) + } + }) + } +}