Rust in Everything
基于 sqlx 0.9.0

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。

📝NOTE
冷知识:0.8.6 的 verify-ca 其实也校验主机名同一个程序在 0.8.6 下,只有一行不同:
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,并保证连接用的主机名在证书里。

💬 关联讨论

发起讨论
加载中...