porsager / porsager/postgres

Cloudflare build: a failed TLS handshake (untrusted CA, e.g. Supabase) surfaces as CONNECT_TIMEOUT instead of the real error

Open
#1,213 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
8.7k
Forks
374
Avg merge
11d 16h
Merged PRs (30d)
1

Description

Version

postgres@3.4.9, Cloudflare Workers build (cf/src/index.js + cf/polyfills.js), nodejs_compat, compatibility date 2025-10-01.

Summary

When the server's certificate is not trusted by the Workers runtime (private CA), ssl: 'require' and ssl: 'prefer' both fail with CONNECT_TIMEOUT after connect_timeout seconds, with no hint that TLS is the cause. The same host connects and queries fine with ssl: false, and raw cloudflare:sockets open to it in ~100 ms, so the pooler, the network and SCRAM auth are all fine: only the TLS step is broken, and the error is lost.

Target: Supabase's pooler (*.pooler.supabase.com:6543, Supavisor) and direct host (db.<ref>.supabase.co:5432). Both present a chain issued by the private "Supabase Root 2021 CA" (openssl s_client -starttls postgresVerify return code: 19 (self-signed certificate in certificate chain)).

What actually happens at the socket level

Replaying the same choreography by hand on cloudflare:sockets (SSLRequest → 'S'startTls() → StartupMessage → first reply), from the same Worker:

variant result
secureTransport: 'starttls', then startTls() TCP open at 102 ms, 'S' at 194 ms, then Error: TLS Handshake Failed. on the first write/read of the upgraded socket
secureTransport: 'on' (TLS-first) proxy request failed, cannot connect to the specified address (Supavisor does not speak direct TLS either way)
plain, no TLS R AuthenticationSASL SCRAM-SHA-256 at 183 ms

Workers TCP sockets always validate the server certificate and have no rejectUnauthorized/custom-CA option (cloudflare/workers-sdk#3366; node:tls on workerd throws The options.rejectUnauthorized option is not implemented). So on Workers, ssl: 'require'/'prefer'/'allow' cannot do what they do in Node (rejectUnauthorized: false): the handshake is rejected by the runtime.

That part is a platform limitation, not a postgres.js bug. The bug is that the failure never reaches the caller.

Where the error is lost (cf/polyfills.js)

In tls.connect:

tcp.raw = tcp.raw.startTls({ servername })
tcp.raw.closed.then(
  () => tcp.emit('close'),
  (e) => tcp.emit('error', e)
)
tcp.writer = tcp.raw.writable.getWriter()
tcp.reader = tcp.raw.readable.getReader()

tcp.writer.ready.then(() => {
  tcp.read()
  tcp.readyState = 'upgrade'
})
return tcp

and secureConnect is only emitted from the original socket's closed handler (in connect()), when readyState === 'upgrade':

tcp.raw.closed.then(
  () => {
    tcp.readyState !== 'upgrade'
      ? close()
      : ((tcp.readyState = 'open'), tcp.emit('secureConnect'))
  },
  (e) => tcp.emit('error', e)
)

When the handshake fails, tcp.writer.ready never resolves (or rejects, with no rejection handler on that .then), readyState stays 'upgrading', secureConnect never fires, nothing is ever written or read on the upgraded socket, and the only thing that eventually happens is connectTimedOut() in src/connection.js. Observed behaviour: 5 s of silence then CONNECT_TIMEOUT, for both 'require' and 'prefer' (with 'prefer' the fallback to plaintext cannot trigger either, since the server did answer 'S').

For comparison, pg + pg-cloudflare on the same Worker and host surfaces Connection terminated unexpectedly immediately, which at least points at the transport.

Suggestion
  1. In the polyfill's tls.connect, handle the rejection of tcp.writer.ready (and of the upgraded socket's closed) by routing it to error(err), so the caller gets the runtime's TLS Handshake Failed. (or the certificate error) instead of a timeout.
  2. A note in the Cloudflare section of the README: on Workers the certificate is always verified by the runtime, ssl: 'require'/'prefer' cannot skip verification, and a server with a private CA needs Hyperdrive (which accepts a custom CA) or a publicly trusted certificate.
Repro
import postgres from 'postgres' // Workers build
const sql = postgres(process.env.SUPABASE_POOLER_URL, {
  ssl: 'require', prepare: false, max: 1, connect_timeout: 5,
})
await sql`select 1` // → CONNECT_TIMEOUT after 5 s ; with ssl: false → ok

Happy to open a PR for (1) if that direction sounds right.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in cf/polyfills.js at tls.connect and compare its error handling with the original socket flow in connect(); then inspect src/connection.js around connectTimedOut(). Reproduce the Cloudflare Workers case with ssl: 'require' against the Supabase endpoint, and consider the work complete when the TLS failure reaches the caller instead of becoming CONNECT_TIMEOUT and the Cloudflare README explains the certificate limitation.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, postgresql, supabase
Domain
backend, cloud, database
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.