Enable TLS automatically for Amazon RDS endpoints - #4
Conversation
A connection to an *.rds.amazonaws.com address now verifies against Amazon's
RDS root bundle, embedded here, unless the DSN asked for something else.
Three Block repositories had already written this: strata's pkg/mysqlrds,
vitess's go/vt/topo/mysqltopo, and spirit's pkg/dbconn each carry a copy of the
bundle, a hostname regexp, and a RegisterTLSConfig call. They agree on what
should happen and disagree on the details — two of the three regexps require
the leading dot in `.rds.amazonaws.com` and one does not, one pins a TLS
minimum version and two take the Go default, one checks the result of
AppendCertsFromPEM and two discard it, and strata's bundle is three
ca-west-1 roots newer than the other two. Every consumer of the driver has to
get all of that right independently, and getting it wrong by omission produces
an unencrypted connection rather than an error.
The driver is where this belongs: it is the only layer that sees every
connection, and the address is all the input it needs.
Design:
- The hook is one line in Config.normalize, so it covers both entry points
(ParseDSN and NewConnector) and everything downstream of them. All the
logic is in rds.go, a file upstream does not have.
- It fires only when neither cfg.TLS nor cfg.TLSConfig is set, so anything
the DSN specifies wins — including tls=false, which is the documented
opt-out. normalize then fills in ServerName as it does for any other
config, making this identity verification and not just encryption.
- The regexp requires the leading dot. Without it `notrds.amazonaws.com`
matches; that fails safely (verification against RDS roots fails rather
than trusting the wrong CA) but a confusing handshake error is still worse
than not matching.
- MinVersion is TLS 1.2 rather than the Go default, so a future change to
that default cannot quietly weaken an RDS connection.
- The bundle is the newest of the three (strata's, which has ca-west-1), and
covers the aws partition only — which is why the regexp does not match the
China or GovCloud endpoint forms. RDSTLSConfig() is exported for anything
that needs the trust store under a different name.
Tests cover the endpoint patterns including the near-misses, the precedence of
each way a DSN can specify TLS, the independence of the per-connection configs
(normalize writes ServerName into them), and the bundle itself — a truncated
PEM would otherwise parse into an empty pool and fail every RDS connection at
handshake time, a long way from the mistake.
Full suite passes against MySQL 8.0.44, race enabled.
Coverage Report for CI Build 34064570862Coverage increased (+0.2%) to 84.575%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
staticcheck flags Subjects() as deprecated (SA1019). Walking the bundle is better anyway: AppendCertsFromPEM reports success if it parsed any one certificate, and the loop that counts unexpired roots was already doing the walk — so this now also names the offending certificate when one fails to parse.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes default transport security behavior in a database driver and embeds/depends on a large CA bundle, so it warrants final human review despite strong tests.
Pull request overview
This PR adds driver-level automatic TLS enablement for connections targeting Amazon RDS/Aurora endpoints by defaulting to an embedded AWS RDS root CA bundle when the DSN/Config does not explicitly specify a TLS mode, making verified encryption the default for *.rds.amazonaws.com addresses.
Changes:
- Add
rds.goimplementing RDS endpoint detection and anRDSTLSConfig()helper backed by an embedded global RDS bundle (TLS min 1.2). - Invoke RDS auto-TLS from
Config.normalize()when neithercfg.TLSnorcfg.TLSConfigis set. - Add
rds_test.goto validate endpoint matching, DSN precedence, and bundle parse/expiry sanity.
File summaries
| File | Description |
|---|---|
| README.md | Documents the new “RDS auto-TLS” capability and how it fits the fork’s goals. |
| dsn.go | Adds a Config.normalize() hook to apply RDS auto-TLS before existing TLS selection logic. |
| rds.go | Implements RDS endpoint matching, embedded bundle parsing, and auto-TLS application logic. |
| rds_test.go | Adds tests for address matching, precedence rules, and embedded bundle validity checks. |
| rdsGlobalBundle.pem | Adds the embedded Amazon RDS global trust bundle used for verification. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
🤖 Adversarial correctness review — This is the right shape for the problem: address-derived rather than DSN-derived means the safe default can't be lost by a Also worth saying, since it's the reason this matters beyond convenience: this makes spirit's Two things I'd want resolved before merge, both measured.
1 — GovCloud matches the pattern and isn't in the bundle (med){"mydb.cxyz123.us-gov-west-1.rds.amazonaws.com:3306", true},I parsed the embedded bundle and looked for what would have to be there for that to be safe: China is excluded by hostname and documented — The consequence is a behaviour change in the direction this file is trying to avoid. Before: a GovCloud DSN with no Copilot's review comment is pointing at the same seam from the doc side. The revision in
2 — one CertPool behind every config, and the doc invites mutating it (med)var rdsRootCAs = sync.OnceValue(func() *x509.CertPool { ... })
func RDSTLSConfig() *tls.Config {
return &tls.Config{RootCAs: rdsRootCAs(), MinVersion: tls.VersionTLS12}
}Caching the parse is right — parsing 121 certs per connection would be silly. But the doc comment three lines up says:
The config is new; the pool inside it is not, and "trust a different partition's bundle" is precisely the operation that reaches through into it. One caller following the documented advice widens the trust store for every RDS connection in the process, including auto-TLS connections that never asked. And because
3 — the match is case-sensitive (low)DNS is case-insensitive and nothing upstream lowercases 4 — the DSN doesn't carry the TLS it got (low)Inside the fork this is a feature — the property is derived from the address, so it can't be dropped by a round trip, which is strictly better than the Not something to fix in code; the useful version is one line in the README next to "Anything the DSN specifies still wins": auto-TLS is a property of the address, not of the DSN string, so a DSN produced here carries no Verified — the runs, the bundle, and three attacks that dissolvedLocal. The bundle is what it claims to be. 121 certificates, all parsing, Attack that dissolved: the fork addition landing in the wrong half of Attack that dissolved: Attack that dissolved: the empty-pool failure mode. Leak check: clean. The one internal reference is "Block had three separate copies of exactly that before this existed" in The probe files were moved out of the tree; the worktree is clean at This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving — the shape is right and the normalize() placement is correct on all three orderings that matter. Two measured findings worth resolving first, in the review comment above: GovCloud endpoints match the pattern but have zero roots in the embedded bundle (a working plaintext connection becomes a failing handshake), and every RDSTLSConfig() shares one CertPool while the doc invites mutating it.
This stamp was left by Claude Code (claude-opus-5).
Three fixes from review, each with a test that fails without it: GovCloud RDS endpoints are ordinary <name>.<hash>.us-gov-<region>.rds. amazonaws.com names, so the suffix check accepted them — but the embedded bundle has 121 commercial roots and zero GovCloud ones. Auto-TLS would have turned a GovCloud connection that works today in plaintext into a failing handshake with an x509 error naming none of this: the same confusing failure the leading dot in rdsAddr exists to avoid, reached from the other direction. Excluded, with RDSTLSConfig as the documented path. RDSTLSConfig's doc invites callers to modify the returned config, but every call shared one *x509.CertPool, which has no copy-on-write. One caller following that advice widened trust for every RDS connection in the process, and raced with in-flight handshakes reading the pool. Clone it — a shallow index copy, cheap next to a handshake. DNS is case-insensitive and nothing normalizes cfg.Addr, so a hostname uppercased by a config file or a console copy-paste got no auto-TLS at all: the silent-plaintext omission this file exists to prevent. Also document that auto-TLS is a property of the address, not the DSN string, so FormatDSN output carries no tls= and only reproduces the behaviour when reopened with block-mysql.
|
🤖 All four addressed in
1 — GovCloud excluded. I confirmed the bundle independently before changing anything: 121 certificates, 2 — pool cloned. 3 — 4 — README. Added the paragraph: auto-TLS is a property of the address, not the DSN string, so On your framing point — agreed, and it is now on the retirement list: this makes spirit's |
A connection to an
*.rds.amazonaws.comaddress now verifies against Amazon's RDS root bundle, embedded here, unless the DSN asked for something else.Why in the driver
Three Block repositories have already written this independently:
AppendCertsFromPEMpkg/mysqlrdsrds\.amazonaws\.com(:\d+)?$go/vt/topo/mysqltopo\.rds\.amazonaws\.com(:\d+)?$pkg/dbconn\.rds\.amazonaws\.com(:\d+)?$They agree on what should happen and disagree on every detail — including the leading dot, which is the difference between matching RDS and matching anything ending in
rds.amazonaws.com. Each also carries its own ~180KB copy of the bundle, and strata's is threeca-west-1roots newer than the other two.The driver is the only layer that sees every connection, and the endpoint address is the entire input this needs. Getting it wrong by omission produces an unencrypted connection to a production database rather than an error, which is the kind of default worth moving.
Design
Config.normalize, so it covers both entry points (ParseDSNandNewConnector) and everything downstream. All the logic is inrds.go, a file upstream does not have — the merge-forward cost is that single call.cfg.TLSnorcfg.TLSConfigis set, sotls=falseis a working opt-out, as areskip-verifyand any registered config.normalizethen fillsServerNameas it does for any config, making this identity verification rather than just encryption.notrds.amazonaws.commatches. That fails safely — verification against RDS roots fails rather than trusting the wrong CA — but a confusing handshake error is still worse than not matching.MinVersionis TLS 1.2, not the Go default, so a future change to that default cannot quietly weaken an RDS connection.awspartition only — which is why the regexp deliberately does not match the China (.amazonaws.com.cn) or other-partition endpoint forms, whose roots are not in it.RDSTLSConfig()is exported for an RDS instance reached under a name that doesn't look like one (a CNAME, or a proxy).Tests
rds_test.gocovers the endpoint patterns including the near-misses (notrds.amazonaws.com, an RDS label mid-domain, the cn partition), the precedence of each way a DSN can specify TLS, and the independence of per-connection configs —normalizewritesServerNameintocfg.TLS, so a shared config would let one connection's expected identity overwrite another's.TestRDSGlobalBundleParsesguards the bundle itself: a truncated PEM parses into an empty pool and then fails every RDS connection at handshake time, a long way from the mistake. It also fails if every root has expired. Currently 121 roots, 93 unexpired.Full suite passes against MySQL 8.0.44 with
-race.After this and the
rejectReadOnlychange mergestrata, vitess and spirit can each delete their copy — bundle, regexp,
RegisterTLSConfigcall and the branches around it.