From 99dc20b1c0cc5bed210859b97ed2c4ecc17dc91c Mon Sep 17 00:00:00 2001 From: bookernath Date: Sun, 1 Mar 2026 15:09:46 -0800 Subject: [PATCH] feat(pglite-socket): handle SSLRequest and CancelRequest wire protocol messages Add support for PostgreSQL SSLRequest and CancelRequest protocol messages during the connection startup phase. This enables pglite-socket to work behind connection proxies like Cloudflare Hyperdrive that send SSLRequest during connection negotiation. Changes: - SSLRequest (code 80877103): respond with 'N' (no SSL), per PostgreSQL wire protocol spec - CancelRequest (code 80877102): silently acknowledge and discard - Add startupComplete flag to distinguish startup-phase untyped messages from regular typed messages after handshake - Add tests for SSLRequest response, post-SSLRequest connectivity, and CancelRequest resilience Without this fix, SSLRequest bytes fall through to the typed message parser, which misinterprets them and corrupts the protocol stream, causing PGLite to crash and the proxy to receive ECONNRESET. Co-Authored-By: Claude Opus 4.6 --- .changeset/ssl-cancel-request-handling.md | 7 ++ packages/pglite-socket/src/index.ts | 62 ++++++++++++---- .../tests/query-with-node-pg.test.ts | 74 +++++++++++++++++++ 3 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 .changeset/ssl-cancel-request-handling.md diff --git a/.changeset/ssl-cancel-request-handling.md b/.changeset/ssl-cancel-request-handling.md new file mode 100644 index 000000000..e979f0d02 --- /dev/null +++ b/.changeset/ssl-cancel-request-handling.md @@ -0,0 +1,7 @@ +--- +'@electric-sql/pglite-socket': minor +--- + +Handle SSLRequest and CancelRequest wire protocol messages for proxy compatibility + +Added support for PostgreSQL SSLRequest and CancelRequest protocol messages that arrive during the connection startup phase. SSLRequest is answered with 'N' (no SSL), and CancelRequest is silently acknowledged. This enables pglite-socket to work behind connection proxies like Cloudflare Hyperdrive that send SSLRequest during connection negotiation. diff --git a/packages/pglite-socket/src/index.ts b/packages/pglite-socket/src/index.ts index 6bf0d3369..9a95b1f0f 100644 --- a/packages/pglite-socket/src/index.ts +++ b/packages/pglite-socket/src/index.ts @@ -171,6 +171,7 @@ export class PGLiteSocketHandler extends EventTarget { private debug: boolean private readonly id: number private messageBuffer: Buffer = Buffer.alloc(0) + private startupComplete = false private idleTimer?: NodeJS.Timeout private idleTimeout: number private lastActivityTime: number = Date.now() @@ -303,6 +304,7 @@ export class PGLiteSocketHandler extends EventTarget { this.socket = null this.active = false this.messageBuffer = Buffer.alloc(0) + this.startupComplete = false this.log(`detach: handler cleaned up`) return this @@ -333,28 +335,54 @@ export class PGLiteSocketHandler extends EventTarget { // Determine message length let messageLength = 0 let isComplete = false + let isStartupMessage = false - // Handle startup message (no type byte, just length) - if (this.messageBuffer.length >= 4) { + // During startup phase, check for untyped protocol messages + // (SSLRequest, CancelRequest, StartupMessage — all lack a type byte) + if (!this.startupComplete && this.messageBuffer.length >= 8) { const firstInt = this.messageBuffer.readInt32BE(0) - - if (this.messageBuffer.length >= 8) { - const secondInt = this.messageBuffer.readInt32BE(4) - // PostgreSQL 3.0 protocol version - if (secondInt === 196608 || secondInt === 0x00030000) { - messageLength = firstInt - isComplete = this.messageBuffer.length >= messageLength + const secondInt = this.messageBuffer.readInt32BE(4) + + // SSLRequest: client asks if server supports SSL + // Format: [length=8][code=80877103 (0x04D2162F)] + // Response: single byte 'N' (no SSL support) + if (firstInt === 8 && secondInt === 80877103) { + this.messageBuffer = this.messageBuffer.slice(8) + if (this.socket && this.socket.writable) { + this.socket.write(Buffer.from('N')) } + this.log( + 'handleData: SSLRequest received, responded with N (no ssl)', + ) + continue + } + + // CancelRequest: client asks to cancel a running query + // Format: [length=16][code=80877102 (0x04D2162E)][processID][secretKey] + // Response: none — server silently ignores + if (firstInt === 16 && secondInt === 80877102) { + this.messageBuffer = this.messageBuffer.slice(16) + this.log( + 'handleData: CancelRequest received, ignoring (not supported)', + ) + continue } - // Regular message (type byte + length) - if (!isComplete && this.messageBuffer.length >= 5) { - const msgLength = this.messageBuffer.readInt32BE(1) - messageLength = 1 + msgLength + // StartupMessage: [length][version=196608 (3.0)] + if (secondInt === 196608 || secondInt === 0x00030000) { + messageLength = firstInt isComplete = this.messageBuffer.length >= messageLength + isStartupMessage = true } } + // Regular typed message (type byte + 4-byte length) + if (!isComplete && this.messageBuffer.length >= 5) { + const msgLength = this.messageBuffer.readInt32BE(1) + messageLength = 1 + msgLength + isComplete = this.messageBuffer.length >= messageLength + } + if (!isComplete || messageLength === 0) { this.log( `handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`, @@ -366,6 +394,14 @@ export class PGLiteSocketHandler extends EventTarget { const message = this.messageBuffer.slice(0, messageLength) this.messageBuffer = this.messageBuffer.slice(messageLength) + // Mark startup phase as complete after processing the startup message + if (isStartupMessage) { + this.startupComplete = true + this.log( + 'handleData: startup complete, switching to typed message mode', + ) + } + this.log(`handleData: processing message of ${message.length} bytes`) // Check if socket is still active before processing diff --git a/packages/pglite-socket/tests/query-with-node-pg.test.ts b/packages/pglite-socket/tests/query-with-node-pg.test.ts index aa54db7f4..d63610158 100644 --- a/packages/pglite-socket/tests/query-with-node-pg.test.ts +++ b/packages/pglite-socket/tests/query-with-node-pg.test.ts @@ -11,6 +11,7 @@ import { Client } from 'pg' import { PGlite } from '@electric-sql/pglite' import { PGLiteSocketServer } from '../src' import { spawn, ChildProcess } from 'node:child_process' +import { createConnection } from 'node:net' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import fs from 'fs' @@ -792,6 +793,79 @@ describe(`PGLite Socket Server`, () => { // swallow } }, 30000) + + it('should handle SSLRequest by responding with N', async () => { + // Test raw SSLRequest wire protocol handling + const socket = createConnection({ host: '127.0.0.1', port: TEST_PORT }) + + await new Promise((resolve, reject) => { + socket.on('connect', resolve) + socket.on('error', reject) + }) + + // Send SSLRequest: [length=8][code=80877103 (0x04D2162F)] + const sslRequest = Buffer.alloc(8) + sslRequest.writeInt32BE(8, 0) + sslRequest.writeInt32BE(80877103, 4) + socket.write(sslRequest) + + // Read response — should be exactly 'N' (1 byte) + const response = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timeout waiting for SSLRequest response')), + 5000, + ) + socket.once('data', (data) => { + clearTimeout(timeout) + resolve(data) + }) + }) + + expect(response.length).toBe(1) + expect(response.toString()).toBe('N') + + socket.destroy() + }) + + it('should handle SSLRequest then accept normal connection', async () => { + // Verify the server still accepts connections after handling SSLRequest + // (i.e., one connection's SSLRequest doesn't break the server) + const testClient = new Client(connectionConfig) + await testClient.connect() + const result = await testClient.query('SELECT 42 as answer') + expect(result.rows[0].answer).toBe(42) + await testClient.end() + }) + + it('should handle CancelRequest without crashing', async () => { + // Test raw CancelRequest wire protocol handling + const socket = createConnection({ host: '127.0.0.1', port: TEST_PORT }) + + await new Promise((resolve, reject) => { + socket.on('connect', resolve) + socket.on('error', reject) + }) + + // Send CancelRequest: [length=16][code=80877102 (0x04D2162E)][processID=0][secretKey=0] + const cancelRequest = Buffer.alloc(16) + cancelRequest.writeInt32BE(16, 0) + cancelRequest.writeInt32BE(80877102, 4) + cancelRequest.writeInt32BE(0, 8) // processID + cancelRequest.writeInt32BE(0, 12) // secretKey + socket.write(cancelRequest) + + // Wait briefly for server to process (no response expected) + await new Promise((resolve) => setTimeout(resolve, 200)) + + socket.destroy() + + // Verify server still works after receiving CancelRequest + const testClient = new Client(connectionConfig) + await testClient.connect() + const result = await testClient.query('SELECT 1 as one') + expect(result.rows[0].one).toBe(1) + await testClient.end() + }) }) describe('with extensions via CLI', () => {