Skip to content

Commit 99dc20b

Browse files
bookernathclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 9633a1e commit 99dc20b

3 files changed

Lines changed: 130 additions & 13 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@electric-sql/pglite-socket': minor
3+
---
4+
5+
Handle SSLRequest and CancelRequest wire protocol messages for proxy compatibility
6+
7+
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.

packages/pglite-socket/src/index.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export class PGLiteSocketHandler extends EventTarget {
171171
private debug: boolean
172172
private readonly id: number
173173
private messageBuffer: Buffer = Buffer.alloc(0)
174+
private startupComplete = false
174175
private idleTimer?: NodeJS.Timeout
175176
private idleTimeout: number
176177
private lastActivityTime: number = Date.now()
@@ -303,6 +304,7 @@ export class PGLiteSocketHandler extends EventTarget {
303304
this.socket = null
304305
this.active = false
305306
this.messageBuffer = Buffer.alloc(0)
307+
this.startupComplete = false
306308

307309
this.log(`detach: handler cleaned up`)
308310
return this
@@ -333,28 +335,54 @@ export class PGLiteSocketHandler extends EventTarget {
333335
// Determine message length
334336
let messageLength = 0
335337
let isComplete = false
338+
let isStartupMessage = false
336339

337-
// Handle startup message (no type byte, just length)
338-
if (this.messageBuffer.length >= 4) {
340+
// During startup phase, check for untyped protocol messages
341+
// (SSLRequest, CancelRequest, StartupMessage — all lack a type byte)
342+
if (!this.startupComplete && this.messageBuffer.length >= 8) {
339343
const firstInt = this.messageBuffer.readInt32BE(0)
340-
341-
if (this.messageBuffer.length >= 8) {
342-
const secondInt = this.messageBuffer.readInt32BE(4)
343-
// PostgreSQL 3.0 protocol version
344-
if (secondInt === 196608 || secondInt === 0x00030000) {
345-
messageLength = firstInt
346-
isComplete = this.messageBuffer.length >= messageLength
344+
const secondInt = this.messageBuffer.readInt32BE(4)
345+
346+
// SSLRequest: client asks if server supports SSL
347+
// Format: [length=8][code=80877103 (0x04D2162F)]
348+
// Response: single byte 'N' (no SSL support)
349+
if (firstInt === 8 && secondInt === 80877103) {
350+
this.messageBuffer = this.messageBuffer.slice(8)
351+
if (this.socket && this.socket.writable) {
352+
this.socket.write(Buffer.from('N'))
347353
}
354+
this.log(
355+
'handleData: SSLRequest received, responded with N (no ssl)',
356+
)
357+
continue
358+
}
359+
360+
// CancelRequest: client asks to cancel a running query
361+
// Format: [length=16][code=80877102 (0x04D2162E)][processID][secretKey]
362+
// Response: none — server silently ignores
363+
if (firstInt === 16 && secondInt === 80877102) {
364+
this.messageBuffer = this.messageBuffer.slice(16)
365+
this.log(
366+
'handleData: CancelRequest received, ignoring (not supported)',
367+
)
368+
continue
348369
}
349370

350-
// Regular message (type byte + length)
351-
if (!isComplete && this.messageBuffer.length >= 5) {
352-
const msgLength = this.messageBuffer.readInt32BE(1)
353-
messageLength = 1 + msgLength
371+
// StartupMessage: [length][version=196608 (3.0)]
372+
if (secondInt === 196608 || secondInt === 0x00030000) {
373+
messageLength = firstInt
354374
isComplete = this.messageBuffer.length >= messageLength
375+
isStartupMessage = true
355376
}
356377
}
357378

379+
// Regular typed message (type byte + 4-byte length)
380+
if (!isComplete && this.messageBuffer.length >= 5) {
381+
const msgLength = this.messageBuffer.readInt32BE(1)
382+
messageLength = 1 + msgLength
383+
isComplete = this.messageBuffer.length >= messageLength
384+
}
385+
358386
if (!isComplete || messageLength === 0) {
359387
this.log(
360388
`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`,
@@ -366,6 +394,14 @@ export class PGLiteSocketHandler extends EventTarget {
366394
const message = this.messageBuffer.slice(0, messageLength)
367395
this.messageBuffer = this.messageBuffer.slice(messageLength)
368396

397+
// Mark startup phase as complete after processing the startup message
398+
if (isStartupMessage) {
399+
this.startupComplete = true
400+
this.log(
401+
'handleData: startup complete, switching to typed message mode',
402+
)
403+
}
404+
369405
this.log(`handleData: processing message of ${message.length} bytes`)
370406

371407
// Check if socket is still active before processing

packages/pglite-socket/tests/query-with-node-pg.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Client } from 'pg'
1111
import { PGlite } from '@electric-sql/pglite'
1212
import { PGLiteSocketServer } from '../src'
1313
import { spawn, ChildProcess } from 'node:child_process'
14+
import { createConnection } from 'node:net'
1415
import { fileURLToPath } from 'node:url'
1516
import { dirname, join } from 'node:path'
1617
import fs from 'fs'
@@ -792,6 +793,79 @@ describe(`PGLite Socket Server`, () => {
792793
// swallow
793794
}
794795
}, 30000)
796+
797+
it('should handle SSLRequest by responding with N', async () => {
798+
// Test raw SSLRequest wire protocol handling
799+
const socket = createConnection({ host: '127.0.0.1', port: TEST_PORT })
800+
801+
await new Promise<void>((resolve, reject) => {
802+
socket.on('connect', resolve)
803+
socket.on('error', reject)
804+
})
805+
806+
// Send SSLRequest: [length=8][code=80877103 (0x04D2162F)]
807+
const sslRequest = Buffer.alloc(8)
808+
sslRequest.writeInt32BE(8, 0)
809+
sslRequest.writeInt32BE(80877103, 4)
810+
socket.write(sslRequest)
811+
812+
// Read response — should be exactly 'N' (1 byte)
813+
const response = await new Promise<Buffer>((resolve, reject) => {
814+
const timeout = setTimeout(
815+
() => reject(new Error('Timeout waiting for SSLRequest response')),
816+
5000,
817+
)
818+
socket.once('data', (data) => {
819+
clearTimeout(timeout)
820+
resolve(data)
821+
})
822+
})
823+
824+
expect(response.length).toBe(1)
825+
expect(response.toString()).toBe('N')
826+
827+
socket.destroy()
828+
})
829+
830+
it('should handle SSLRequest then accept normal connection', async () => {
831+
// Verify the server still accepts connections after handling SSLRequest
832+
// (i.e., one connection's SSLRequest doesn't break the server)
833+
const testClient = new Client(connectionConfig)
834+
await testClient.connect()
835+
const result = await testClient.query('SELECT 42 as answer')
836+
expect(result.rows[0].answer).toBe(42)
837+
await testClient.end()
838+
})
839+
840+
it('should handle CancelRequest without crashing', async () => {
841+
// Test raw CancelRequest wire protocol handling
842+
const socket = createConnection({ host: '127.0.0.1', port: TEST_PORT })
843+
844+
await new Promise<void>((resolve, reject) => {
845+
socket.on('connect', resolve)
846+
socket.on('error', reject)
847+
})
848+
849+
// Send CancelRequest: [length=16][code=80877102 (0x04D2162E)][processID=0][secretKey=0]
850+
const cancelRequest = Buffer.alloc(16)
851+
cancelRequest.writeInt32BE(16, 0)
852+
cancelRequest.writeInt32BE(80877102, 4)
853+
cancelRequest.writeInt32BE(0, 8) // processID
854+
cancelRequest.writeInt32BE(0, 12) // secretKey
855+
socket.write(cancelRequest)
856+
857+
// Wait briefly for server to process (no response expected)
858+
await new Promise((resolve) => setTimeout(resolve, 200))
859+
860+
socket.destroy()
861+
862+
// Verify server still works after receiving CancelRequest
863+
const testClient = new Client(connectionConfig)
864+
await testClient.connect()
865+
const result = await testClient.query('SELECT 1 as one')
866+
expect(result.rows[0].one).toBe(1)
867+
await testClient.end()
868+
})
795869
})
796870

797871
describe('with extensions via CLI', () => {

0 commit comments

Comments
 (0)