Skip to content

Commit 882fc30

Browse files
authored
Add support for sslnegotiation=direct (PostgreSQL 17) (brianc#3688)
PostgreSQL 17 added the `sslnegotiation` connection parameter, which allows clients to start the TLS handshake immediately after the TCP connection ("direct" negotiation) instead of first sending an SSLRequest packet and waiting for the server's S/N reply ("postgres" negotiation, the default and prior behavior). Direct negotiation saves one network round-trip and works with protocol-agnostic TLS tooling. - connection.js: extract the TLS upgrade into upgradeToSSL(); in direct mode upgrade the socket right after connect (skipping the SSLRequest exchange) and advertise the `postgresql` ALPN protocol as the server requires. - client.js: forward sslNegotiation to the Connection and skip requestSsl() in direct mode. - connection-parameters.js: read sslnegotiation from config / PGSSLNEGOTIATION, validate it is `postgres` or `direct`, require SSL to be enabled for `direct`, and include it in the libpq connection string. - pg-connection-string: parse the sslnegotiation query param and enable SSL automatically when `direct` is requested without other SSL config. - docs: document the new option. - tests: cover connection-string parsing, connection-parameters validation, and the direct-vs-traditional connection behavior (no SSLRequest packet, ALPN set only for direct). Closes brianc#3346
1 parent c4dfbba commit 882fc30

10 files changed

Lines changed: 251 additions & 23 deletions

File tree

docs/pages/features/ssl.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,31 @@ const config = {
4949
}
5050
```
5151

52+
## Direct SSL negotiation
53+
54+
By default node-postgres uses the traditional PostgreSQL SSL negotiation: it sends an `SSLRequest` packet, waits for the server to acknowledge it, and only then starts the TLS handshake. PostgreSQL 17 and newer also support _direct_ SSL negotiation, where the TLS handshake begins immediately on connect (similar to HTTPS), saving one network round-trip.
55+
56+
To use direct negotiation, set `sslnegotiation: 'direct'`. SSL must be enabled, and the server must be PostgreSQL 17+ configured to accept direct SSL connections.
57+
58+
```js
59+
const config = {
60+
database: 'database-name',
61+
host: 'host-or-ip',
62+
ssl: { rejectUnauthorized: false },
63+
sslnegotiation: 'direct',
64+
}
65+
```
66+
67+
It can also be supplied via a connection string. When `sslnegotiation=direct` is present, SSL is enabled automatically if not otherwise configured:
68+
69+
```js
70+
const config = {
71+
connectionString: 'postgres://user:password@host:port/db?sslmode=require&sslnegotiation=direct',
72+
}
73+
```
74+
75+
Direct negotiation requests the `postgresql` ALPN protocol during the TLS handshake, as required by the server. The default value is `'postgres'`, which preserves the traditional `SSLRequest` behavior. You can also set the `PGSSLNEGOTIATION` environment variable.
76+
5277
## Channel binding
5378

5479
If the PostgreSQL server offers SCRAM-SHA-256-PLUS (i.e. channel binding) for TLS/SSL connections, you can enable this as follows:

packages/pg-connection-string/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface ConnectionOptions {
2222
database: string | null | undefined
2323
client_encoding?: string
2424
ssl?: boolean | string | SSLConfig
25+
sslnegotiation?: 'postgres' | 'direct'
2526

2627
application_name?: string
2728
fallback_application_name?: string

packages/pg-connection-string/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ function parse(str, options = {}) {
7878
config.ssl = {}
7979
}
8080

81+
// sslnegotiation=direct implies SSL is in use (libpq requires sslmode>=require),
82+
// so enable SSL if the connection string did not otherwise configure it.
83+
if (config.sslnegotiation === 'direct' && config.ssl === undefined) {
84+
config.ssl = true
85+
}
86+
8187
// Only try to load fs if we expect to read from the disk
8288
const fs = config.sslcert || config.sslkey || config.sslrootcert ? require('fs') : null
8389

packages/pg-connection-string/test/parse.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,29 @@ describe('parse', function () {
216216
subject.ssl?.should.equal(true)
217217
})
218218

219+
it('configuration parameter sslnegotiation=direct', function () {
220+
const connectionString = 'pg:///?sslnegotiation=direct'
221+
const subject = parse(connectionString)
222+
subject.sslnegotiation?.should.equal('direct')
223+
// direct negotiation implies SSL is enabled
224+
subject.ssl?.should.equal(true)
225+
})
226+
227+
it('configuration parameter sslnegotiation=postgres', function () {
228+
const connectionString = 'pg:///?sslnegotiation=postgres'
229+
const subject = parse(connectionString)
230+
subject.sslnegotiation?.should.equal('postgres')
231+
// traditional negotiation does not change ssl
232+
;(subject.ssl === undefined).should.equal(true)
233+
})
234+
235+
it('sslnegotiation=direct keeps an explicit ssl config', function () {
236+
const connectionString = 'pg:///?sslnegotiation=direct&sslmode=require'
237+
const subject = parse(connectionString)
238+
subject.sslnegotiation?.should.equal('direct')
239+
subject.ssl?.should.eql({})
240+
})
241+
219242
it('configuration parameter sslcert=/path/to/cert', function () {
220243
const connectionString = 'pg:///?sslcert=' + __dirname + '/example.cert'
221244
const subject = parse(connectionString)

packages/pg/lib/client.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class Client extends EventEmitter {
9191
new Connection({
9292
stream: c.stream,
9393
ssl: this.connectionParameters.ssl,
94+
sslNegotiation: this.connectionParameters.sslnegotiation,
9495
keepAlive: c.keepAlive || false,
9596
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
9697
encoding: this.connectionParameters.client_encoding || 'utf8',
@@ -100,6 +101,7 @@ class Client extends EventEmitter {
100101
this.processID = null
101102
this.secretKey = null
102103
this.ssl = this.connectionParameters.ssl || false
104+
this.sslNegotiation = this.connectionParameters.sslnegotiation || 'postgres'
103105
// As with Password, make SSL->Key (the private key) non-enumerable.
104106
// It won't show up in stack traces
105107
// or if the client is console.logged
@@ -177,7 +179,11 @@ class Client extends EventEmitter {
177179
// once connection is established send startup message
178180
con.on('connect', function () {
179181
if (self.ssl) {
180-
con.requestSsl()
182+
// With direct SSL negotiation the connection upgrades to TLS without an
183+
// SSLRequest packet, so the startup message is sent after 'sslconnect'.
184+
if (self.sslNegotiation !== 'direct') {
185+
con.requestSsl()
186+
}
181187
} else {
182188
con.startup(self.getStartupConf())
183189
}

packages/pg/lib/connection-parameters.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ class ConnectionParameters {
9999
})
100100
}
101101

102+
// How to negotiate SSL: 'postgres' (default, the traditional SSLRequest
103+
// handshake) or 'direct' (start the TLS handshake immediately on connect).
104+
this.sslnegotiation = val('sslnegotiation', config, 'PGSSLNEGOTIATION')
105+
if (this.sslnegotiation !== undefined && this.sslnegotiation !== 'postgres' && this.sslnegotiation !== 'direct') {
106+
throw new Error(
107+
`Invalid sslnegotiation value: "${this.sslnegotiation}". Valid values are "postgres" and "direct".`
108+
)
109+
}
110+
if (this.sslnegotiation === 'direct' && !this.ssl) {
111+
throw new Error('sslnegotiation=direct requires SSL to be enabled')
112+
}
113+
102114
this.client_encoding = val('client_encoding', config)
103115
this.replication = val('replication', config)
104116
// a domain socket begins with '/'
@@ -144,6 +156,7 @@ class ConnectionParameters {
144156
add(params, ssl, 'sslkey')
145157
add(params, ssl, 'sslcert')
146158
add(params, ssl, 'sslrootcert')
159+
add(params, this, 'sslnegotiation')
147160

148161
if (this.database) {
149162
params.push('dbname=' + quoteParamValue(this.database))

packages/pg/lib/connection.js

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
const EventEmitter = require('events').EventEmitter
44

55
const { parse, serialize } = require('pg-protocol')
6-
const { getStream, getSecureStream } = require('./stream')
6+
const stream = require('./stream')
7+
const { getStream } = stream
78

89
const flushBuffer = serialize.flush()
910
const syncBuffer = serialize.sync()
@@ -24,6 +25,7 @@ class Connection extends EventEmitter {
2425
this._keepAliveInitialDelayMillis = config.keepAliveInitialDelayMillis
2526
this.parsedStatements = {}
2627
this.ssl = config.ssl || false
28+
this.sslNegotiation = config.sslNegotiation || 'postgres'
2729
this._ending = false
2830
this._emitMessage = false
2931
const self = this
@@ -65,6 +67,14 @@ class Connection extends EventEmitter {
6567
return this.attachListeners(this.stream)
6668
}
6769

70+
// With direct SSL negotiation the TLS handshake starts immediately on the
71+
// raw socket, skipping the SSLRequest packet and the server's 'S'/'N' reply.
72+
if (this.sslNegotiation === 'direct') {
73+
return this.stream.once('connect', function () {
74+
self.upgradeToSSL(host, reportStreamError)
75+
})
76+
}
77+
6878
this.stream.once('data', function (buffer) {
6979
const responseCode = buffer.toString('utf8')
7080
switch (responseCode) {
@@ -78,32 +88,43 @@ class Connection extends EventEmitter {
7888
self.stream.end()
7989
return self.emit('error', new Error('There was an error establishing an SSL connection'))
8090
}
81-
const options = {
82-
socket: self.stream,
83-
}
91+
self.upgradeToSSL(host, reportStreamError)
92+
})
93+
}
8494

85-
if (self.ssl !== true) {
86-
Object.assign(options, self.ssl)
95+
upgradeToSSL(host, reportStreamError) {
96+
const self = this
97+
const options = {
98+
socket: self.stream,
99+
}
87100

88-
if ('key' in self.ssl) {
89-
options.key = self.ssl.key
90-
}
91-
}
101+
if (self.ssl !== true) {
102+
Object.assign(options, self.ssl)
92103

93-
const net = require('net')
94-
if (net.isIP && net.isIP(host) === 0) {
95-
options.servername = host
104+
if ('key' in self.ssl) {
105+
options.key = self.ssl.key
96106
}
97-
try {
98-
self.stream = getSecureStream(options)
99-
} catch (err) {
100-
return self.emit('error', err)
101-
}
102-
self.attachListeners(self.stream)
103-
self.stream.on('error', reportStreamError)
107+
}
104108

105-
self.emit('sslconnect')
106-
})
109+
// Direct SSL negotiation requires ALPN so the server can confirm it is
110+
// speaking the PostgreSQL protocol over the TLS connection.
111+
if (self.sslNegotiation === 'direct') {
112+
options.ALPNProtocols = ['postgresql']
113+
}
114+
115+
const net = require('net')
116+
if (net.isIP && net.isIP(host) === 0) {
117+
options.servername = host
118+
}
119+
try {
120+
self.stream = stream.getSecureStream(options)
121+
} catch (err) {
122+
return self.emit('error', err)
123+
}
124+
self.attachListeners(self.stream)
125+
self.stream.on('error', reportStreamError)
126+
127+
self.emit('sslconnect')
107128
}
108129

109130
attachListeners(stream) {

packages/pg/lib/defaults.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ module.exports = {
4949

5050
ssl: false,
5151

52+
// SSL negotiation style: 'postgres' (traditional SSLRequest) or 'direct'
53+
sslnegotiation: undefined,
54+
5255
application_name: undefined,
5356

5457
fallback_application_name: undefined,

packages/pg/test/unit/connection-parameters/creation-tests.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,62 @@ suite.test('ssl is set on client', function () {
358358
})
359359
)
360360
})
361+
362+
suite.test('sslnegotiation defaults to undefined', function () {
363+
const subject = new ConnectionParameters({})
364+
assert.strictEqual(subject.sslnegotiation, undefined)
365+
})
366+
367+
suite.test('sslnegotiation=direct is read from config', function () {
368+
const subject = new ConnectionParameters({ ssl: true, sslnegotiation: 'direct' })
369+
assert.strictEqual(subject.sslnegotiation, 'direct')
370+
})
371+
372+
suite.test('sslnegotiation=postgres is read from config', function () {
373+
const subject = new ConnectionParameters({ ssl: true, sslnegotiation: 'postgres' })
374+
assert.strictEqual(subject.sslnegotiation, 'postgres')
375+
})
376+
377+
suite.test('sslnegotiation rejects invalid values', function () {
378+
assert.throws(() => new ConnectionParameters({ ssl: true, sslnegotiation: 'bogus' }), /Invalid sslnegotiation value/)
379+
})
380+
381+
suite.test('sslnegotiation=direct requires ssl', function () {
382+
assert.throws(() => new ConnectionParameters({ ssl: false, sslnegotiation: 'direct' }), /requires SSL to be enabled/)
383+
})
384+
385+
suite.test('sslnegotiation is read from PGSSLNEGOTIATION env var', function () {
386+
const original = process.env.PGSSLNEGOTIATION
387+
process.env.PGSSLNEGOTIATION = 'direct'
388+
try {
389+
const subject = new ConnectionParameters({ ssl: true })
390+
assert.strictEqual(subject.sslnegotiation, 'direct')
391+
} finally {
392+
if (original === undefined) {
393+
delete process.env.PGSSLNEGOTIATION
394+
} else {
395+
process.env.PGSSLNEGOTIATION = original
396+
}
397+
}
398+
})
399+
400+
suite.test('sslnegotiation is included in libpq connection string', function () {
401+
const subject = new ConnectionParameters({
402+
user: 'brian',
403+
host: 'localhost',
404+
port: 5432,
405+
database: 'postgres',
406+
ssl: true,
407+
sslnegotiation: 'direct',
408+
})
409+
subject.getLibpqConnectionString(
410+
assert.calls(function (err, pgCString) {
411+
assert(!err)
412+
assert.equal(
413+
pgCString.indexOf("sslnegotiation='direct'") !== -1,
414+
true,
415+
'libpqConnectionString should contain sslnegotiation'
416+
)
417+
})
418+
)
419+
})

packages/pg/test/unit/connection/error-tests.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,77 @@ const SSLNegotiationPacketTests = [
6060
},
6161
]
6262

63+
suite.test('direct SSL negotiation upgrades to TLS without an SSLRequest packet', function (done) {
64+
const con = new Connection({ stream: new MemoryStream(), ssl: true, sslNegotiation: 'direct' })
65+
66+
// capture the upgrade instead of performing a real TLS handshake
67+
let upgradeCalled = false
68+
con.upgradeToSSL = function () {
69+
upgradeCalled = true
70+
}
71+
72+
con.connect(1234, 'localhost')
73+
74+
// simulate the raw socket connecting
75+
con.stream.emit('connect')
76+
77+
// no SSLRequest packet should have been written to the underlying stream
78+
assert.equal(con.stream.packets.length, 0, 'direct negotiation must not send an SSLRequest packet')
79+
assert.equal(upgradeCalled, true, 'direct negotiation must upgrade to TLS on connect')
80+
done()
81+
})
82+
83+
suite.test('direct SSL negotiation passes ALPN protocol to the secure stream', function (done) {
84+
const streamModule = require('../../../lib/stream')
85+
const originalGetSecureStream = streamModule.getSecureStream
86+
87+
let capturedOptions = null
88+
streamModule.getSecureStream = function (options) {
89+
capturedOptions = options
90+
return options.socket
91+
}
92+
93+
try {
94+
const con = new Connection({ stream: new MemoryStream(), ssl: true, sslNegotiation: 'direct' })
95+
con.connect(1234, 'localhost')
96+
con.stream.emit('connect')
97+
98+
assert(capturedOptions, 'getSecureStream should have been called')
99+
assert.deepEqual(
100+
capturedOptions.ALPNProtocols,
101+
['postgresql'],
102+
'direct negotiation must request the postgresql ALPN protocol'
103+
)
104+
done()
105+
} finally {
106+
streamModule.getSecureStream = originalGetSecureStream
107+
}
108+
})
109+
110+
suite.test('traditional SSL negotiation does not set ALPN protocol', function (done) {
111+
const streamModule = require('../../../lib/stream')
112+
const originalGetSecureStream = streamModule.getSecureStream
113+
114+
let capturedOptions = null
115+
streamModule.getSecureStream = function (options) {
116+
capturedOptions = options
117+
return options.socket
118+
}
119+
120+
try {
121+
const con = new Connection({ stream: new MemoryStream(), ssl: true })
122+
con.connect(1234, 'localhost')
123+
// traditional path: server signals SSL support with an 'S' byte
124+
con.stream.emit('data', Buffer.from('S'))
125+
126+
assert(capturedOptions, 'getSecureStream should have been called')
127+
assert.equal(capturedOptions.ALPNProtocols, undefined, 'traditional negotiation must not request ALPN')
128+
done()
129+
} finally {
130+
streamModule.getSecureStream = originalGetSecureStream
131+
}
132+
})
133+
63134
for (const tc of SSLNegotiationPacketTests) {
64135
suite.test(tc.testName, function (done) {
65136
// our fake postgres server

0 commit comments

Comments
 (0)