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
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ same package cover the rest of it.
| CLI grammar / config | [internal/cli](../internal/cli/cli_test.go) |
| Pool config, bounded session timeouts | [pkg/dbconn](../pkg/dbconn/pool_config_test.go), [integration](../pkg/dbconn/dbconn_integration_test.go) |
| Retry classification and behavior | [pkg/dbconn/retry_test.go](../pkg/dbconn/retry_test.go) |
| RDS/Aurora TLS (unit) | [pkg/dbconn/rds_test.go](../pkg/dbconn/rds_test.go) |
| RDS/Aurora TLS (unit), every host of a multi-host URL included | [pkg/dbconn/rds_test.go](../pkg/dbconn/rds_test.go), [multi-host (unit)](../pkg/dbconn/rds_tls_test.go) |
| Verify-full TLS against a live TLS-only server; a CA bundle never falls back to plaintext against a server that accepts both (`TestTLSIntegrationNoPlaintextFallback`, `StartPostgresTLSOrPlaintext`) | [pkg/dbconn/tls_integration_test.go](../pkg/dbconn/tls_integration_test.go), [CA pinning (unit)](../pkg/dbconn/ca_tls_test.go) |
| Targeted blocker termination | [pkg/dbconn/dbconn_integration_test.go](../pkg/dbconn/dbconn_integration_test.go) |
| Test harness self-checks | [internal/testutil](../internal/testutil/postgres_test.go) |
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func New(version string) *CLI {
// database, so every entry point carries the same bounded session defaults.
type DBFlags struct {
URL string `help:"PostgreSQL connection URL or key=value DSN." env:"PGSPRITE_URL" required:""`
CACert string `help:"CA bundle path for verify-full TLS. Overrides the URL's sslmode: every network host is verified against the bundle, with no plaintext or unverified fallback; refused with sslmode=disable. RDS/Aurora endpoints verify with the embedded bundle automatically." env:"PGSPRITE_CA_CERT" type:"existingfile"`
CACert string `help:"CA bundle path for verify-full TLS. Overrides the URL's sslmode: every network host is verified against the bundle, with no plaintext or unverified fallback; refused with sslmode=disable. RDS/Aurora endpoints verify with the embedded bundle automatically, every host of a multi-host URL included." env:"PGSPRITE_CA_CERT" type:"existingfile"`
LockTimeout time.Duration `help:"Session lock_timeout applied to every statement. At least 1ms; 0 uses the default." default:"3s"`
StatementTimeout time.Duration `help:"Session statement_timeout applied to every statement. At least 1ms; 0 uses the default." default:"30s"`
Debug bool `help:"Log statement-level tracing and lifecycle diagnostics to stderr."`
Expand Down
69 changes: 4 additions & 65 deletions pkg/dbconn/ca_tls.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package dbconn

import (
"crypto/tls"
"errors"

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

Expand All @@ -13,76 +11,17 @@ import (
// encrypted session and the mode asks for none, and neither is safe to guess.
var ErrCACertWithSSLModeDisable = errors.New("a CA bundle (--ca-cert, PGSPRITE_CA_CERT, or Config.CACertPath) requires TLS but the connection string selects sslmode=disable; remove sslmode=disable or the CA bundle")

// hostPort identifies one dial target of a possibly multi-host connection.
type hostPort struct {
host string
port uint16
}

// pinToCABundle makes every network connection attempt of pc verify-full
// against the CA bundle at caCertPath, each against its own host name.
//
// pgx expresses sslmode=prefer (the default) and sslmode=allow as extra
// fallback attempts per host: an unencrypted one, or an encrypted one that
// skips verification. Keeping those would let a server that fails the
// verified handshake — a certificate from another CA, or a man in the middle
// — push the pool onto a connection that is unencrypted or unauthenticated.
// Each distinct host and port keeps exactly one attempt, so multi-host
// failover stays while every attempt is verified. Each attempt keeps the
// client certificate and ALPN protocols pgx parsed for it (sslcert / sslkey,
// sslnegotiation=direct), since they authenticate the client, not the server.
//
// A unix-domain socket attempt keeps pgx's nil TLS config: PostgreSQL does
// not speak TLS over a socket, libpq ignores sslmode for one, and the socket
// never leaves the host, so there is no server to verify.
// against the CA bundle at cfg.CACertPath, each against its own host name, with
// no unencrypted or unverified fallback (see pinEveryAttempt).
func pinToCABundle(pc *pgxpool.Config, cfg Config) error {
if dsnSSLMode(cfg.URL) == "disable" {
return ErrCACertWithSSLModeDisable
}
cc := pc.ConnConfig
verified, err := caTLSConfig(cfg.CACertPath, cc.Host)
roots, err := loadCABundle(cfg.CACertPath)
if err != nil {
return err
}

// The client-side settings pgx parsed for each target, from the first
// attempt at it that carries TLS: sslmode=allow tries plaintext first.
parsed := map[hostPort]*tls.Config{}
note := func(target hostPort, c *tls.Config) {
if c != nil && parsed[target] == nil {
parsed[target] = c
}
}
note(hostPort{cc.Host, cc.Port}, cc.TLSConfig)
for _, fb := range cc.Fallbacks {
note(hostPort{fb.Host, fb.Port}, fb.TLSConfig)
}
attempt := func(target hostPort) *tls.Config {
if network, _ := pgconn.NetworkAddress(target.host, target.port); network == "unix" {
return nil
}
c := verified.Clone()
c.ServerName = target.host
if p := parsed[target]; p != nil {
c.Certificates = p.Certificates
c.GetClientCertificate = p.GetClientCertificate
c.NextProtos = p.NextProtos
}
return c
}

primary := hostPort{cc.Host, cc.Port}
cc.TLSConfig = attempt(primary)
seen := map[hostPort]bool{primary: true}
fallbacks := make([]*pgconn.FallbackConfig, 0, len(cc.Fallbacks))
for _, fb := range cc.Fallbacks {
target := hostPort{fb.Host, fb.Port}
if seen[target] {
continue
}
seen[target] = true
fallbacks = append(fallbacks, &pgconn.FallbackConfig{Host: fb.Host, Port: fb.Port, TLSConfig: attempt(target)})
}
cc.Fallbacks = fallbacks
pinEveryAttempt(&pc.ConnConfig.Config, roots)
return nil
}
63 changes: 27 additions & 36 deletions pkg/dbconn/dbconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@ type Config struct {
// socket. A URL that selects sslmode=disable alongside it is refused with
// ErrCACertWithSSLModeDisable. Unset, RDS/Aurora endpoints in
// the partitions the embedded bundle covers are auto-verified with it
// (see rds.go); an RDS/Aurora endpoint in any other partition (GovCloud)
// refuses to open with ErrRDSBundleDoesNotCoverHost unless its
// connection string selects an sslmode that needs no roots.
// (see rds.go), each host of a multi-host URL against its own name; an
// RDS/Aurora endpoint in any other partition (GovCloud) refuses to open
// with ErrRDSBundleDoesNotCoverHost unless its connection string selects
// an sslmode that needs no roots, and a URL with no sslmode that lists an
// RDS/Aurora endpoint beside another host refuses with
// ErrMixedRDSAndOtherHosts.
CACertPath string

// Pool sizing and lifecycle. Zero values keep pgxpool's defaults.
Expand Down Expand Up @@ -363,9 +366,8 @@ func (l slogTraceLogger) Log(ctx context.Context, level tracelog.LogLevel, msg s
l.logger.Log(ctx, slogLevel, msg, attrs...)
}

// caTLSConfig builds a verify-full TLS config trusting only the given CA
// bundle, verifying the server certificate against host.
func caTLSConfig(caCertPath, host string) (*tls.Config, error) {
// loadCABundle reads the CA bundle at caCertPath into a cert pool.
func loadCABundle(caCertPath string) (*x509.CertPool, error) {
pem, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("read CA bundle: %w", err)
Expand All @@ -374,52 +376,41 @@ func caTLSConfig(caCertPath, host string) (*tls.Config, error) {
if !roots.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("CA bundle %s contains no usable certificates", caCertPath)
}
return &tls.Config{
RootCAs: roots,
ServerName: host,
MinVersion: tls.VersionTLS12,
}, nil
return roots, nil
}

// configureTLS decides the pool's TLS setup: an explicit CA bundle wins;
// otherwise RDS/Aurora endpoints get TLS automatically via the embedded RDS
// global bundle (see rds.go); everything else keeps whatever the connection
// string asked for.
//
// An RDS/Aurora endpoint the embedded bundle cannot verify never falls
// through to pgx's default, which is encrypted-but-unauthenticated with a
// plaintext fallback: without an sslmode of its own, or with one that asks
// for verification the bundle cannot supply, it is an error naming the
// remedy. An explicit sslmode that needs no roots is honored on every
// RDS/Aurora endpoint alike.
// Every host of a multi-host connection string is judged, not just the
// first. An RDS/Aurora endpoint the embedded bundle cannot verify never
// falls through to pgx's default, which is encrypted-but-unauthenticated
// with a plaintext fallback: without an sslmode of its own, or with one that
// asks for verification the bundle cannot supply, it is an error naming the
// host and the remedy, and so is a host beside an RDS/Aurora endpoint that
// is not one when no sslmode is named. An explicit sslmode that needs no
// roots is honored on every attempt alike.
func configureTLS(pc *pgxpool.Config, cfg Config) error {
host := pc.ConnConfig.Host
cc := &pc.ConnConfig.Config
switch {
case cfg.CACertPath != "":
return pinToCABundle(pc, cfg)
case !IsRDSHost(host):
// Not a managed endpoint: the connection string's choice stands.
case !listsRDSHost(cc):
// No managed endpoint: the connection string's choice stands.
return nil
case dsnNamesSSLMode(cfg.URL):
// The caller chose an sslmode; honor it — but when verification
// The caller chose an sslmode; honor it — but where verification
// was requested without a root bundle, supply the embedded RDS
// roots, which are not in system trust stores.
if !requestsVerificationWithoutRoots(pc.ConnConfig.TLSConfig) {
return nil
}
if !RDSBundleCovers(host) {
return uncoveredRDSHostError(host)
}
pc.ConnConfig.TLSConfig.RootCAs = rdsRootPool()
case !RDSBundleCovers(host):
return uncoveredRDSHostError(host)
return supplyRDSRoots(cc)
default:
// No explicit sslmode on an RDS/Aurora endpoint: auto-enable
// verify-full with the embedded bundle, and drop the plaintext
// fallbacks the default sslmode would otherwise allow.
pc.ConnConfig.TLSConfig = rdsTLSConfig(host)
pc.ConnConfig.Fallbacks = nil
// No explicit sslmode: auto-enable verify-full with the embedded
// bundle on every host, dropping the plaintext fallbacks the
// default sslmode would otherwise allow.
return pinToRDSBundle(cc)
}
return nil
}

// requestsVerificationWithoutRoots reports whether a parsed TLS config asks
Expand Down
11 changes: 0 additions & 11 deletions pkg/dbconn/rds.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dbconn

import (
"crypto/tls"
"crypto/x509"
_ "embed"
"errors"
Expand Down Expand Up @@ -69,13 +68,3 @@ func rdsRootPool() *x509.CertPool {
}
return pool
}

// rdsTLSConfig returns a verify-full TLS config for an RDS/Aurora host using
// the embedded global bundle.
func rdsTLSConfig(host string) *tls.Config {
return &tls.Config{
RootCAs: rdsRootPool(),
ServerName: host,
MinVersion: tls.VersionTLS12,
}
}
9 changes: 0 additions & 9 deletions pkg/dbconn/rds_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dbconn

import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"os"
Expand Down Expand Up @@ -98,14 +97,6 @@ func TestEmbeddedRDSBundleCoversOnlyCommercialRegions(t *testing.T) {
}
}

func TestRDSTLSConfig(t *testing.T) {
tc := rdsTLSConfig("mydb.abc123.us-east-1.rds.amazonaws.com")
assert.Equal(t, "mydb.abc123.us-east-1.rds.amazonaws.com", tc.ServerName)
assert.NotNil(t, tc.RootCAs)
assert.Equal(t, uint16(tls.VersionTLS12), tc.MinVersion)
assert.False(t, tc.InsecureSkipVerify)
}

func TestConfigureTLS(t *testing.T) {
const rdsURL = "postgres://user@mydb.abc123.us-east-1.rds.amazonaws.com:5432/app"
const govCloudURL = "postgres://user@mydb.abc123.us-gov-west-1.rds.amazonaws.com:5432/app"
Expand Down
95 changes: 95 additions & 0 deletions pkg/dbconn/rds_tls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package dbconn

import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"slices"

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

// ErrMixedRDSAndOtherHosts reports a connection string that names no sslmode
// and lists an RDS/Aurora endpoint alongside a host that is not one. The
// embedded RDS bundle verifies only the endpoint, and leaving the other host
// on pgx's default would let it fall back to an unverified or plaintext
// session, so neither is chosen silently.
var ErrMixedRDSAndOtherHosts = errors.New("the connection string lists an RDS/Aurora endpoint alongside a host that is not one, so the embedded RDS CA bundle cannot verify every host")

// listsRDSHost reports whether any network host of cc is an RDS/Aurora
// endpoint.
func listsRDSHost(cc *pgconn.Config) bool {
return slices.ContainsFunc(networkHosts(cc), IsRDSHost)
}

// pinToRDSBundle auto-enables verify-full with the embedded RDS bundle on a
// connection string that names no sslmode: every network host keeps one
// verified attempt (see pinEveryAttempt), so multi-host failover survives.
//
// Every network host must be an RDS/Aurora endpoint the bundle covers; the
// first that is not is refused by name, before anything is changed.
func pinToRDSBundle(cc *pgconn.Config) error {
for _, host := range networkHosts(cc) {
if !IsRDSHost(host) {
return mixedRDSHostsError(host)
}
if !RDSBundleCovers(host) {
return uncoveredRDSHostError(host)
}
}
pinEveryAttempt(cc, rdsRootPool())
return nil
}

// supplyRDSRoots honors the caller's explicit sslmode on every attempt, but
// gives each RDS/Aurora attempt that asks for verification without roots
// the embedded RDS roots, which are not in system trust stores. Other
// attempts — modes that need no roots, attempts with roots of their own,
// hosts that are not RDS/Aurora endpoints — are left as pgx parsed them.
//
// An RDS/Aurora attempt asking for verification in a partition the bundle
// does not cover is refused by name rather than left to fail its handshake.
func supplyRDSRoots(cc *pgconn.Config) error {
roots := rdsRootPool()
primary, err := withRDSRoots(cc.Host, cc.TLSConfig, roots)
if err != nil {
return err
}
cc.TLSConfig = primary
for _, fb := range cc.Fallbacks {
attempt, err := withRDSRoots(fb.Host, fb.TLSConfig, roots)
if err != nil {
return err
}
fb.TLSConfig = attempt
}
return nil
}

// withRDSRoots returns the TLS config for one attempt at host: c itself,
// or, when host is an RDS/Aurora endpoint and c asks for verification
// without roots, a copy of c verifying against roots.
func withRDSRoots(host string, c *tls.Config, roots *x509.CertPool) (*tls.Config, error) {
if !IsRDSHost(host) {
return c, nil
}
if !requestsVerificationWithoutRoots(c) {
return c, nil
}
if !RDSBundleCovers(host) {
return nil, uncoveredRDSHostError(host)
}
c = c.Clone()
c.RootCAs = roots
return c, nil
}

// mixedRDSHostsError names the host the embedded bundle cannot verify beside
// an RDS/Aurora endpoint and the two ways to verify every host.
func mixedRDSHostsError(host string) error {
return fmt.Errorf("configure TLS for %s: %w; set CACertPath to a CA bundle that verifies every host, "+
"or choose an sslmode in the connection string (sslmode=verify-full verifies RDS/Aurora endpoints "+
"against the embedded bundle and other hosts against the system roots)",
host, ErrMixedRDSAndOtherHosts)
}
Loading
Loading