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
25 changes: 25 additions & 0 deletions docs/pages/features/ssl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ const config = {
}
```

## Direct SSL negotiation

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.

To use direct negotiation, set `sslnegotiation: 'direct'`. SSL must be enabled, and the server must be PostgreSQL 17+ configured to accept direct SSL connections.

```js
const config = {
database: 'database-name',
host: 'host-or-ip',
ssl: { rejectUnauthorized: false },
sslnegotiation: 'direct',
}
```

It can also be supplied via a connection string. When `sslnegotiation=direct` is present, SSL is enabled automatically if not otherwise configured:

```js
const config = {
connectionString: 'postgres://user:password@host:port/db?sslmode=require&sslnegotiation=direct',
}
```

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.

## Channel binding

If the PostgreSQL server offers SCRAM-SHA-256-PLUS (i.e. channel binding) for TLS/SSL connections, you can enable this as follows:
Expand Down
1 change: 1 addition & 0 deletions packages/pg-connection-string/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ConnectionOptions {
database: string | null | undefined
client_encoding?: string
ssl?: boolean | string | SSLConfig
sslnegotiation?: 'postgres' | 'direct'

application_name?: string
fallback_application_name?: string
Expand Down
6 changes: 6 additions & 0 deletions packages/pg-connection-string/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ function parse(str, options = {}) {
config.ssl = {}
}

// sslnegotiation=direct implies SSL is in use (libpq requires sslmode>=require),
// so enable SSL if the connection string did not otherwise configure it.
if (config.sslnegotiation === 'direct' && config.ssl === undefined) {
config.ssl = true
}

// Only try to load fs if we expect to read from the disk
const fs = config.sslcert || config.sslkey || config.sslrootcert ? require('fs') : null

Expand Down
23 changes: 23 additions & 0 deletions packages/pg-connection-string/test/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,29 @@ describe('parse', function () {
subject.ssl?.should.equal(true)
})

it('configuration parameter sslnegotiation=direct', function () {
const connectionString = 'pg:///?sslnegotiation=direct'
const subject = parse(connectionString)
subject.sslnegotiation?.should.equal('direct')
// direct negotiation implies SSL is enabled
subject.ssl?.should.equal(true)
})

it('configuration parameter sslnegotiation=postgres', function () {
const connectionString = 'pg:///?sslnegotiation=postgres'
const subject = parse(connectionString)
subject.sslnegotiation?.should.equal('postgres')
// traditional negotiation does not change ssl
;(subject.ssl === undefined).should.equal(true)
})

it('sslnegotiation=direct keeps an explicit ssl config', function () {
const connectionString = 'pg:///?sslnegotiation=direct&sslmode=require'
const subject = parse(connectionString)
subject.sslnegotiation?.should.equal('direct')
subject.ssl?.should.eql({})
})

it('configuration parameter sslcert=/path/to/cert', function () {
const connectionString = 'pg:///?sslcert=' + __dirname + '/example.cert'
const subject = parse(connectionString)
Expand Down
8 changes: 7 additions & 1 deletion packages/pg/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class Client extends EventEmitter {
new Connection({
stream: c.stream,
ssl: this.connectionParameters.ssl,
sslNegotiation: this.connectionParameters.sslnegotiation,
keepAlive: c.keepAlive || false,
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
encoding: this.connectionParameters.client_encoding || 'utf8',
Expand All @@ -100,6 +101,7 @@ class Client extends EventEmitter {
this.processID = null
this.secretKey = null
this.ssl = this.connectionParameters.ssl || false
this.sslNegotiation = this.connectionParameters.sslnegotiation || 'postgres'
// As with Password, make SSL->Key (the private key) non-enumerable.
// It won't show up in stack traces
// or if the client is console.logged
Expand Down Expand Up @@ -177,7 +179,11 @@ class Client extends EventEmitter {
// once connection is established send startup message
con.on('connect', function () {
if (self.ssl) {
con.requestSsl()
// With direct SSL negotiation the connection upgrades to TLS without an
// SSLRequest packet, so the startup message is sent after 'sslconnect'.
if (self.sslNegotiation !== 'direct') {
con.requestSsl()
}
} else {
con.startup(self.getStartupConf())
}
Expand Down
13 changes: 13 additions & 0 deletions packages/pg/lib/connection-parameters.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ class ConnectionParameters {
})
}

// How to negotiate SSL: 'postgres' (default, the traditional SSLRequest
// handshake) or 'direct' (start the TLS handshake immediately on connect).
this.sslnegotiation = val('sslnegotiation', config, 'PGSSLNEGOTIATION')
if (this.sslnegotiation !== undefined && this.sslnegotiation !== 'postgres' && this.sslnegotiation !== 'direct') {
throw new Error(
`Invalid sslnegotiation value: "${this.sslnegotiation}". Valid values are "postgres" and "direct".`
)
}
if (this.sslnegotiation === 'direct' && !this.ssl) {
throw new Error('sslnegotiation=direct requires SSL to be enabled')
}

this.client_encoding = val('client_encoding', config)
this.replication = val('replication', config)
// a domain socket begins with '/'
Expand Down Expand Up @@ -144,6 +156,7 @@ class ConnectionParameters {
add(params, ssl, 'sslkey')
add(params, ssl, 'sslcert')
add(params, ssl, 'sslrootcert')
add(params, this, 'sslnegotiation')

if (this.database) {
params.push('dbname=' + quoteParamValue(this.database))
Expand Down
65 changes: 43 additions & 22 deletions packages/pg/lib/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
const EventEmitter = require('events').EventEmitter

const { parse, serialize } = require('pg-protocol')
const { getStream, getSecureStream } = require('./stream')
const stream = require('./stream')
const { getStream } = stream

const flushBuffer = serialize.flush()
const syncBuffer = serialize.sync()
Expand All @@ -24,6 +25,7 @@ class Connection extends EventEmitter {
this._keepAliveInitialDelayMillis = config.keepAliveInitialDelayMillis
this.parsedStatements = {}
this.ssl = config.ssl || false
this.sslNegotiation = config.sslNegotiation || 'postgres'
this._ending = false
this._emitMessage = false
const self = this
Expand Down Expand Up @@ -65,6 +67,14 @@ class Connection extends EventEmitter {
return this.attachListeners(this.stream)
}

// With direct SSL negotiation the TLS handshake starts immediately on the
// raw socket, skipping the SSLRequest packet and the server's 'S'/'N' reply.
if (this.sslNegotiation === 'direct') {
return this.stream.once('connect', function () {
self.upgradeToSSL(host, reportStreamError)
})
}

this.stream.once('data', function (buffer) {
const responseCode = buffer.toString('utf8')
switch (responseCode) {
Expand All @@ -78,32 +88,43 @@ class Connection extends EventEmitter {
self.stream.end()
return self.emit('error', new Error('There was an error establishing an SSL connection'))
}
const options = {
socket: self.stream,
}
self.upgradeToSSL(host, reportStreamError)
})
}

if (self.ssl !== true) {
Object.assign(options, self.ssl)
upgradeToSSL(host, reportStreamError) {
const self = this
const options = {
socket: self.stream,
}

if ('key' in self.ssl) {
options.key = self.ssl.key
}
}
if (self.ssl !== true) {
Object.assign(options, self.ssl)

const net = require('net')
if (net.isIP && net.isIP(host) === 0) {
options.servername = host
if ('key' in self.ssl) {
options.key = self.ssl.key
}
try {
self.stream = getSecureStream(options)
} catch (err) {
return self.emit('error', err)
}
self.attachListeners(self.stream)
self.stream.on('error', reportStreamError)
}

self.emit('sslconnect')
})
// Direct SSL negotiation requires ALPN so the server can confirm it is
// speaking the PostgreSQL protocol over the TLS connection.
if (self.sslNegotiation === 'direct') {
options.ALPNProtocols = ['postgresql']
}

const net = require('net')
if (net.isIP && net.isIP(host) === 0) {
options.servername = host
}
try {
self.stream = stream.getSecureStream(options)
} catch (err) {
return self.emit('error', err)
}
self.attachListeners(self.stream)
self.stream.on('error', reportStreamError)

self.emit('sslconnect')
}

attachListeners(stream) {
Expand Down
3 changes: 3 additions & 0 deletions packages/pg/lib/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ module.exports = {

ssl: false,

// SSL negotiation style: 'postgres' (traditional SSLRequest) or 'direct'
sslnegotiation: undefined,

application_name: undefined,

fallback_application_name: undefined,
Expand Down
59 changes: 59 additions & 0 deletions packages/pg/test/unit/connection-parameters/creation-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,62 @@ suite.test('ssl is set on client', function () {
})
)
})

suite.test('sslnegotiation defaults to undefined', function () {
const subject = new ConnectionParameters({})
assert.strictEqual(subject.sslnegotiation, undefined)
})

suite.test('sslnegotiation=direct is read from config', function () {
const subject = new ConnectionParameters({ ssl: true, sslnegotiation: 'direct' })
assert.strictEqual(subject.sslnegotiation, 'direct')
})

suite.test('sslnegotiation=postgres is read from config', function () {
const subject = new ConnectionParameters({ ssl: true, sslnegotiation: 'postgres' })
assert.strictEqual(subject.sslnegotiation, 'postgres')
})

suite.test('sslnegotiation rejects invalid values', function () {
assert.throws(() => new ConnectionParameters({ ssl: true, sslnegotiation: 'bogus' }), /Invalid sslnegotiation value/)
})

suite.test('sslnegotiation=direct requires ssl', function () {
assert.throws(() => new ConnectionParameters({ ssl: false, sslnegotiation: 'direct' }), /requires SSL to be enabled/)
})

suite.test('sslnegotiation is read from PGSSLNEGOTIATION env var', function () {
const original = process.env.PGSSLNEGOTIATION
process.env.PGSSLNEGOTIATION = 'direct'
try {
const subject = new ConnectionParameters({ ssl: true })
assert.strictEqual(subject.sslnegotiation, 'direct')
} finally {
if (original === undefined) {
delete process.env.PGSSLNEGOTIATION
} else {
process.env.PGSSLNEGOTIATION = original
}
}
})

suite.test('sslnegotiation is included in libpq connection string', function () {
const subject = new ConnectionParameters({
user: 'brian',
host: 'localhost',
port: 5432,
database: 'postgres',
ssl: true,
sslnegotiation: 'direct',
})
subject.getLibpqConnectionString(
assert.calls(function (err, pgCString) {
assert(!err)
assert.equal(
pgCString.indexOf("sslnegotiation='direct'") !== -1,
true,
'libpqConnectionString should contain sslnegotiation'
)
})
)
})
71 changes: 71 additions & 0 deletions packages/pg/test/unit/connection/error-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,77 @@ const SSLNegotiationPacketTests = [
},
]

suite.test('direct SSL negotiation upgrades to TLS without an SSLRequest packet', function (done) {
const con = new Connection({ stream: new MemoryStream(), ssl: true, sslNegotiation: 'direct' })

// capture the upgrade instead of performing a real TLS handshake
let upgradeCalled = false
con.upgradeToSSL = function () {
upgradeCalled = true
}

con.connect(1234, 'localhost')

// simulate the raw socket connecting
con.stream.emit('connect')

// no SSLRequest packet should have been written to the underlying stream
assert.equal(con.stream.packets.length, 0, 'direct negotiation must not send an SSLRequest packet')
assert.equal(upgradeCalled, true, 'direct negotiation must upgrade to TLS on connect')
done()
})

suite.test('direct SSL negotiation passes ALPN protocol to the secure stream', function (done) {
const streamModule = require('../../../lib/stream')
const originalGetSecureStream = streamModule.getSecureStream

let capturedOptions = null
streamModule.getSecureStream = function (options) {
capturedOptions = options
return options.socket
}

try {
const con = new Connection({ stream: new MemoryStream(), ssl: true, sslNegotiation: 'direct' })
con.connect(1234, 'localhost')
con.stream.emit('connect')

assert(capturedOptions, 'getSecureStream should have been called')
assert.deepEqual(
capturedOptions.ALPNProtocols,
['postgresql'],
'direct negotiation must request the postgresql ALPN protocol'
)
done()
} finally {
streamModule.getSecureStream = originalGetSecureStream
}
})

suite.test('traditional SSL negotiation does not set ALPN protocol', function (done) {
const streamModule = require('../../../lib/stream')
const originalGetSecureStream = streamModule.getSecureStream

let capturedOptions = null
streamModule.getSecureStream = function (options) {
capturedOptions = options
return options.socket
}

try {
const con = new Connection({ stream: new MemoryStream(), ssl: true })
con.connect(1234, 'localhost')
// traditional path: server signals SSL support with an 'S' byte
con.stream.emit('data', Buffer.from('S'))

assert(capturedOptions, 'getSecureStream should have been called')
assert.equal(capturedOptions.ALPNProtocols, undefined, 'traditional negotiation must not request ALPN')
done()
} finally {
streamModule.getSecureStream = originalGetSecureStream
}
})

for (const tc of SSLNegotiationPacketTests) {
suite.test(tc.testName, function (done) {
// our fake postgres server
Expand Down
Loading