Skip to content
Open
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
46 changes: 45 additions & 1 deletion pkg/postgres/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
42 changes: 42 additions & 0 deletions pkg/postgres/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}