diff --git a/docs/pages/features/ssl.mdx b/docs/pages/features/ssl.mdx index 9983c0434..6a29ed739 100644 --- a/docs/pages/features/ssl.mdx +++ b/docs/pages/features/ssl.mdx @@ -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: diff --git a/packages/pg-connection-string/index.d.ts b/packages/pg-connection-string/index.d.ts index 2ebe67534..4b305299e 100644 --- a/packages/pg-connection-string/index.d.ts +++ b/packages/pg-connection-string/index.d.ts @@ -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 diff --git a/packages/pg-connection-string/index.js b/packages/pg-connection-string/index.js index 4b8d7afb9..7ee302976 100644 --- a/packages/pg-connection-string/index.js +++ b/packages/pg-connection-string/index.js @@ -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 diff --git a/packages/pg-connection-string/test/parse.ts b/packages/pg-connection-string/test/parse.ts index 814e49c58..c2a537581 100644 --- a/packages/pg-connection-string/test/parse.ts +++ b/packages/pg-connection-string/test/parse.ts @@ -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) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index d6c57194c..18280f3c6 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -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', @@ -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 @@ -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()) } diff --git a/packages/pg/lib/connection-parameters.js b/packages/pg/lib/connection-parameters.js index c153932bb..37987fd68 100644 --- a/packages/pg/lib/connection-parameters.js +++ b/packages/pg/lib/connection-parameters.js @@ -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 '/' @@ -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)) diff --git a/packages/pg/lib/connection.js b/packages/pg/lib/connection.js index 027f93935..63cc13a53 100644 --- a/packages/pg/lib/connection.js +++ b/packages/pg/lib/connection.js @@ -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() @@ -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 @@ -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) { @@ -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) { diff --git a/packages/pg/lib/defaults.js b/packages/pg/lib/defaults.js index 673696f79..427243f50 100644 --- a/packages/pg/lib/defaults.js +++ b/packages/pg/lib/defaults.js @@ -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, diff --git a/packages/pg/test/unit/connection-parameters/creation-tests.js b/packages/pg/test/unit/connection-parameters/creation-tests.js index bb6f815a0..e326e2630 100644 --- a/packages/pg/test/unit/connection-parameters/creation-tests.js +++ b/packages/pg/test/unit/connection-parameters/creation-tests.js @@ -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' + ) + }) + ) +}) diff --git a/packages/pg/test/unit/connection/error-tests.js b/packages/pg/test/unit/connection/error-tests.js index 2171a25b6..04f1c3f4b 100644 --- a/packages/pg/test/unit/connection/error-tests.js +++ b/packages/pg/test/unit/connection/error-tests.js @@ -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