Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ssl-cancel-request-handling.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 49 additions & 13 deletions packages/pglite-socket/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions packages/pglite-socket/tests/query-with-node-pg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void>((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<Buffer>((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<void>((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', () => {
Expand Down
Loading