Description
Lemmy is a self-hosted link aggregator and discussion platform written in
Rust. When the database URL carries sslmode=require, which is how an admin
asks Lemmy to reach a remote PostgreSQL over TLS, the connection is wrapped in
an envelope that nobody checks the address on. The handshake encrypts the
traffic but authenticates nothing, so whoever answers at the database address
gets the session.
The TLS client configuration is built by hand through rustls' dangerous
builder and handed a verifier that approves every certificate. establish_connection
and NoCertVerifier at 0.19.20 (crates/db_schema/src/utils.rs:328 and
:376):
fn establish_connection(config: &str) -> BoxFuture<ConnectionResult<AsyncPgConnection>> {
let fut = async {
// We only support TLS with sslmode=require currently
let mut conn = if config.contains("sslmode=require") {
let rustls_config = DangerousClientConfigBuilder {
cfg: ClientConfig::builder(),
}
.with_custom_certificate_verifier(Arc::new(NoCertVerifier {}))
.with_no_client_auth();
impl ServerCertVerifier for NoCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &ServerName,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
// Will verify all (even invalid) certs without any checks (sslmode=require)
Ok(ServerCertVerified::assertion())
}
verify_server_cert returns ServerCertVerified::assertion() without looking
at the chain, the server name, or the expiry. The signature-check methods are
implemented, so the handshake is cryptographically well formed, but a correct
handshake against an unverified key proves only that the peer holds a private
key, not that it is the real database. An on-path attacker presents any
self-signed certificate and the handshake completes.
The comment is honest about the intent. PostgreSQL's own sslmode=require
means "encrypt, do not verify", so the code pairs that keyword with a verifier
that never fails. The deeper problem is that Lemmy offers no stronger option.
The underlying tokio-postgres understands only disable, prefer and
require for sslmode (config.rs), not verify-ca or verify-full, so
there is no URL an admin can write that would turn verification on. Lemmy
drives a remote database over TLS and has no code path that checks the
certificate.
The same code with the same NoCertVerifier sits on main and in the 1.0.0
pre-releases, moved to crates/diesel_utils/src/connection.rs:201. It was
introduced in 2023 when TLS support was first added, so every release from
0.18.1 onward that connects to a remote database with sslmode=require is
affected.
Reproduction
The flaw is in the client's TLS configuration, so it reproduces against any
TLS endpoint that answers on the database port. I built two small clients that
contain only the sslmode=require branch of establish_connection, one with
the dependency versions pinned to Lemmy 0.19.20's Cargo.lock (rustls 0.23.27,
tokio-postgres 0.7.13, tokio-postgres-rustls 0.12.0) and one pinned to
0.19.21's (rustls 0.23.46, tokio-postgres 0.7.16, webpki-roots 1.0.10). Both
install the ring crypto provider at startup, as src/main.rs does.
The rogue endpoint is a short Python script that speaks enough of the
PostgreSQL protocol to accept the SSLRequest, complete TLS with a throwaway
self-signed certificate (CN=attacker.invalid), then request cleartext
password authentication and print what the client sends:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout rogue.key -out rogue.crt -days 2 -subj /CN=attacker.invalid \
-addext basicConstraints=critical,CA:FALSE \
-addext subjectAltName=DNS:attacker.invalid \
-addext extendedKeyUsage=serverAuth
python3 -I rogue_pg.py rogue.crt rogue.key 55432
The 0.19.20 client points at it with the documented remote-DB URL shape:
$ ./lemmy-0-19-20-db-tls 'postgres://lemmy:s3cret-db-pass@localhost:55432/lemmy?sslmode=require'
connect error: Error { kind: Db, cause: Some(DbError { severity: "FATAL",
code: SqlState(E28P01), message: "rogue server, goodbye", .. }) }
The error is the rogue server ending the session on purpose; the client only reaches it after the handshake and the login. The rogue server's transcript shows why that matters:
TLS up (TLSv1.3), startup: {'client_encoding': 'UTF8', 'user': 'lemmy', 'database': 'lemmy'}
password message (p): s3cret-db-pass
The client trusted CN=attacker.invalid, finished the TLS 1.3 handshake, and
handed the database password to the attacker in cleartext. A rogue endpoint
chooses which authentication method to request, and the client's channel
binding defaults to prefer, so asking for a plain password is enough to
collect it. Against a server that insists on SCRAM the attacker instead relays
the session to the real database and reads and rewrites every query, and
captures the SCRAM handshake for an offline guessing attack.
The 0.19.21 client, same URL and same rogue server, refuses:
$ ./lemmy-0-19-21-db-tls 'postgres://lemmy:s3cret-db-pass@localhost:55432/lemmy?sslmode=require'
connect error: Error { kind: Tls, cause: Some(Custom { kind: InvalidData,
error: InvalidCertificate(UnknownIssuer) }) }
The handshake stops before any startup message is sent, so no credentials leave the client.
Impact
Improper certificate validation (CWE-295) that downgrades the database
connection to cleartext against an on-path attacker (CWE-319). It affects every
Lemmy instance whose database lives on another host and is reached with
sslmode=require, which is the one setup where database TLS was meant to help.
An attacker who can answer at the database address, through ARP or DNS spoofing, a rogue host on the database network, or any interception of the hop between the application and the database, becomes the TLS endpoint:
- The database login crosses that endpoint. With password authentication the attacker requests cleartext and reads the password directly, as the reproduction shows; with SCRAM the attacker relays the connection to the real database and man-in-the-middles the whole session.
- Every row in both directions is readable and changeable: user records, email
addresses, password hashes, private messages, the instance signing keys in
the
secret,siteandpersontables, and the query results sent back. - The traffic can be rewritten, so the attacker can alter what Lemmy reads from and writes to its own database.
The advisory scores it CVSS 3.1 7.4
(CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N): reachable across the network,
with the added condition that the attacker hold a position on the path to the
database, and no privileges or user interaction needed. Encrypting the link
while trusting any certificate gave the weaker guarantee of the two, quietly,
to admins who had asked for TLS.
Solution
Fixed in 0.19.21. The fix (crates/db_schema/src/utils.rs, PR
LemmyNet/lemmy#6688 for the 0.19 branch and #6687 on main) deletes
NoCertVerifier and the dangerous builder and uses rustls' default verifier
against the Mozilla root set from the webpki-roots crate:
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let rustls_config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
This keeps the sslmode=require keyword but changes what Lemmy does with it:
the database certificate now has to chain to a public certificate authority and
pass the normal rustls checks, so a self-signed certificate from an attacker is
rejected with UnknownIssuer. Because tokio-postgres cannot express
verify-full and the fix does not add a custom CA parameter, a database that
presents a self-signed certificate or one from a private CA, a common on-premise
setup, is now rejected too. Those deployments need a publicly trusted
certificate on the database, or should terminate TLS at a proxy that has one.
Upgrade to 0.19.21 or later. If you cannot upgrade yet and your database is on
another host, do not rely on sslmode=require for authentication: keep the
database on a private network segment the application reaches directly, pin the
route (static ARP, a dedicated link, or a VPN or TLS tunnel that does verify the
peer), and treat the database credentials as exposed to anything on that path
until the upgrade is in place.
Timeline
- 2023-06-26: TLS support for the database connection added with
NoCertVerifier(LemmyNet/lemmy#3189), first shipped in 0.18.1. - 2026-09-23: Reported privately through the repository's GitHub security advisory (GHSA-pf8v-frhx-f6rx), found with tarnish, my Rust static analyzer.
- 2026-10-07: Fixes opened as #6687 (main) and #6688 (0.19 branch).
- 2026-10-08: #6688 merged and released in 0.19.21-beta.1.
- 2026-10-21: #6687 merged into main.
- 2026-10-26: Lemmy 0.19.21 released and the advisory published. Lemmy through 0.19.20 is affected; 0.19.21 is patched.
References
- GHSA-pf8v-frhx-f6rx - lemmy: sslmode=require installs NoCertVerifier for the database connection
- LemmyNet/lemmy - crates/db_schema/src/utils.rs at 0.19.20
- LemmyNet/lemmy#3189 - Add TLS support for diesel-async database connections
- LemmyNet/lemmy#6688 - [0.19] Use root certs for tls DB connections
- LemmyNet/lemmy#6687 - Use root certs for tls DB connections, rather than allowing any self-signed
- Lemmy Release v0.19.21 - Security Fixes
- PostgreSQL - SSL support and sslmode values
- CWE-295: Improper Certificate Validation
- CWE-319: Cleartext Transmission of Sensitive Information