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, site and person tables, 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