sslmode=require 不校验服务端证书
主线位置:连接池主线里
connect新建连接的第一步,StartupMessage之前的 TLS 升级。SSLRequest怎么发、各个sslmode什么时候退回明文,见建立连接;本页用一个真的 TLS 服务端把「不校验」跑出来。
flowchart TB
mode{"ssl_mode"} -->|"prefer / require"| dummy["DummyTlsVerifier"]
mode -->|verify-ca| nohost["只校验证书链"]
mode -->|verify-full| full["证书链加主机名"]
dummy --> any["任何证书都接受"]
nohost --> ca["要有可信 CA"]
full --> ca
root["sslrootcert"] -.只在 verify 模式读.-> ca
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"(tls-rustls-ring-webpki);对照组是 sqlx = "=0.8.6"(tls-rustls),两者锁定的 rustls 都是 0.23.46。
现象
没有报错,这正是问题:连接串写了 sslmode=require,以为「必须加密、而且对端是真的数据库」,实际上 sqlx 只保证加密,不检查对端是谁。任何能拦在中间的人拿一张自签名证书就能接下这条连接,看到这条连接上的所有查询和结果。
PgSslMode::Require 的文档(ssl_mode.rs)写的是:
/// Only try an SSL connection. If a root CA file is present, verify the connection
/// in the same way as if `VerifyCa` was specified.
Require,
后半句 sqlx 没有实现。
最小复现
实验库本身没开 TLS,所以在实验 crate 里写了一个 TLS 终结代理(src/tls_proxy.rs,约 100 行,rustls 0.23.46 做服务端):读到 8 字节的 SSLRequest 就回 S,用一张证书完成握手,再把解密后的字节原样转给实验库。证书用 openssl 生成:ca.crt 是自建的 CA,它签发的 server.crt 的 SAN 只有 DNS:localhost;other-ca.crt 是一个毫不相干的 CA。
src/bin/sslmode.rs 用不同的 ssl_mode、主机名、根证书去连这个代理,连上就跑一次 select 1:
let mut opts: PgConnectOptions = std::env::var("DATABASE_URL")?.parse()?;
opts = opts.host(host).port(port).ssl_mode(mode);
if let Some(file) = root {
opts = opts.ssl_root_cert(format!("{CERTS}/{file}"));
}
match PgConnection::connect_with(&opts).await {
Ok(mut conn) => { /* select 1,打印「连上了」 */ }
Err(e) => println!("{label} -> {e}"),
}
--- Prefer host=localhost root=-
[tls 1] handshake done: "TLSv1_3", "TLS13_AES_256_GCM_SHA384"
Prefer host=localhost root=- -> 连上了,select 1 = 1
--- Require host=localhost root=-
[tls 2] handshake done: "TLSv1_3", "TLS13_AES_256_GCM_SHA384"
Require host=localhost root=- -> 连上了,select 1 = 1
--- Require host=localhost root=other-ca.crt
[tls 3] handshake done: "TLSv1_3", "TLS13_AES_256_GCM_SHA384"
Require host=localhost root=other-ca.crt -> 连上了,select 1 = 1
--- VerifyCa host=localhost root=other-ca.crt
VerifyCa host=localhost root=other-ca.crt -> error communicating with database: invalid peer certificate: UnknownIssuer
--- VerifyFull host=localhost root=other-ca.crt
VerifyFull host=localhost root=other-ca.crt -> error communicating with database: invalid peer certificate: UnknownIssuer
--- VerifyFull host=localhost root=-
VerifyFull host=localhost root=- -> error communicating with database: invalid peer certificate: UnknownIssuer
--- VerifyCa host=127.0.0.1 root=ca.crt
[tls 7] handshake done: "TLSv1_3", "TLS13_AES_256_GCM_SHA384"
VerifyCa host=127.0.0.1 root=ca.crt -> 连上了,select 1 = 1
--- VerifyFull host=127.0.0.1 root=ca.crt
VerifyFull host=127.0.0.1 root=ca.crt -> error communicating with database: invalid peer certificate: certificate not valid for name "127.0.0.1"; certificate is only valid for DnsName("localhost")
--- VerifyFull host=localhost root=ca.crt
[tls 9] handshake done: "TLSv1_3", "TLS13_AES_256_GCM_SHA384"
VerifyFull host=localhost root=ca.crt -> 连上了,select 1 = 1
第三组是要害:require 配上一个不是签发这张证书的根证书,照样握手成功、查询成功。按文档它应该和 verify-ca 一样报 UnknownIssuer。prefer 在服务端支持 TLS 时也一样不查。
原因
maybe_upgrade 在握手之前把 ssl_mode 换成两个布尔值(源码):
let accept_invalid_certs = !matches!(
options.ssl_mode,
PgSslMode::VerifyCa | PgSslMode::VerifyFull
);
let accept_invalid_hostnames = !matches!(options.ssl_mode, PgSslMode::VerifyFull);
只看模式,不看 ssl_root_cert 有没有设。rustls 后端的 handshake 里,accept_invalid_certs 为真就装 DummyTlsVerifier,它的 verify_server_cert 直接返回 ServerCertVerified::assertion();根证书只在 else 分支里才被读进 cert_store。所以 require 下根证书文件连打开都不会打开。verify-ca 用 NoHostnameTlsVerifier 包一层正常的 webpki 校验,只把「主机名不匹配」那一类错误放过;verify-full 是 rustls 的默认校验。不给 sslrootcert 时,信任的是编译进来的 webpki 根证书(tls-rustls-ring-webpki),自建 CA 不在里面,所以 VerifyFull root=- 报 UnknownIssuer。
这件事上游早有报告:#879(2020 年,标题 Postgres TLS connection accepts invalid certificates when sslmode=require)至今开放。讨论里的回复是 You're correct. This should be fixed.,同时认为 require 配根证书时的校验是 libpq 为兼容保留的旧行为,不值得发安全公告;讨论中引用的 Postgres 文档也说依赖这种行为是 discouraged,需要校验的应用应当用 verify-ca 或 verify-full。
VerifyCa host=127.0.0.1 root=ca.crt -> error communicating with database: invalid peer certificate: certificate not valid for name "127.0.0.1"; certificate is only valid for DnsName("localhost")
NoHostnameTlsVerifier 靠匹配 CertificateError::NotValidForName 来放过主机名错误。rustls 0.23.24 起主机名不匹配改报新变体 NotValidForNameContext(#3793:the explicit handling of the variant NotValidForName is no longer sufficient),0.8.6 不认识它,verify-ca 就退化成了 verify-full。#3861(首次发布于 0.9.0)把两个变体一起匹配,PR 说明是 This fixes the NoHostnameTlsVerifier by also matching on this new variant. 所以在 0.8.6 加新版 rustls 上,用 IP 连、证书里只有域名的部署,verify-ca 会连不上;0.9.0 起恢复成只查证书链。
修法
用 verify-full,并给出签发服务端证书的 CA:
postgres://user:pass@db.example.com/app?sslmode=verify-full&sslrootcert=/etc/ssl/db-ca.pem
或者 PgConnectOptions::ssl_mode(PgSslMode::VerifyFull).ssl_root_cert(path)。上面最后一行就是这种配置:证书链和主机名都对,连上了;主机名写成证书里没有的 127.0.0.1 就被拒。连接时的主机名要和证书 SAN 里的名字一致,云数据库通常给的是域名,别换成 IP。
verify-ca 只在证书确实不带可用的主机名、又必须校验来源时用:它挡住了别的 CA 签的证书,但同一个 CA 签给别人的证书也能冒充你的数据库。
服务端是公网 CA 签发的证书时(webpki 根证书里有),sslrootcert 可以不给,verify-full 照样能校验。
怎么确认自己没中招
- 查所有连接串和
PgSslMode的用法:sslmode是require、prefer或者根本没写(默认prefer),就没有证书校验,不管有没有sslrootcert。环境变量PGSSLMODE也算。 - 改成
verify-full之后先在测试环境连一次:报UnknownIssuer是根证书没给对,报certificate not valid for name是主机名和证书对不上。
小结
- sqlx 的
prefer/require用DummyTlsVerifier,接受任何证书;ssl_root_cert只在verify-ca/verify-full下才会被读。PgSslMode::Require文档里「有根证书就按VerifyCa校验」的说法没有实现(上游 #879,开放中)。 verify-ca校验证书链不校验主机名,verify-full两样都校验;0.8.6 配 rustls 0.23.24 以上时verify-ca也会校验主机名,#3861 在 0.9.0 修好。- 要防中间人,用
verify-full,并保证连接用的主机名在证书里。