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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ jobs:
PGPASSWORD: postgres
PGHOST: localhost
PGDATABASE: ci_db_test
PGTESTNOSSL: 'true'
# PGTESTNOSSL is no longer set: the postgres-ssl service image above has SSL
# configured, so the SSL and SCRAM channel binding tests can run for real here.
SCRAM_TEST_PGUSER: scram_test
SCRAM_TEST_PGPASSWORD: test4scram
SCRAM_TEST_PGUSER_UNICODE: scram_unicode_test
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ For richer information consult the commit log on github with referenced pull req

We do not include break-fix version release in this file.

## pg@8.24.0

- Add support for the `channel_binding` connection parameter (in a connection string, the client config or `PGCHANNELBINDING`): `"disable"`, `"prefer"` or `"require"`, following libpq. Also change default from `disable` to `prefer`, so channel binding is used whenever the server offers it. The previous `enableChannelBinding` boolean option is retained but deprecated: `true` maps to `"prefer"`.
- Also add support for the `require_auth`/`PGREQUIREAUTH` connection parameter, which specifies which authentication method(s) the client will accept from the server.
- Both parameters retain libpq's spelling. A camelCased `channelBinding` or `requireAuth` throw an error rather than leaving a security setting silently ignored, and an unrecognized channel binding value is refused the same way.
- These requirements are enforced against every authentication request, not only SCRAM exchanges, so a server cannot evade `channel_binding=require` by requesting some other kind of authentication (a downgrade recorded against another driver as [CVE-2025-49146](https://www.cve.org/CVERecord?id=CVE-2025-49146)).
- The native (libpq) client validates `channel_binding` and `require_auth` natively, against libpq's wider range of supported auth types.

## pg@8.23.0

- Add support for query [`pipelineing`](https://github.com/brianc/node-postgres/pull/3652).
Expand Down
17 changes: 17 additions & 0 deletions LOCAL_DEV.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Local development

## In a container

The quickest way to get a server the whole suite can run against, SSL included, is the
script that starts the same image CI uses. It works with either podman or docker, and
prints the environment variables to export:

```sh
packages/pg/script/test-server.sh # start it
packages/pg/script/test-server.sh stop # remove it again
```

SSL is worth having even if you are not working on it, since the SCRAM channel binding
tests are skipped without it. Pass `POSTGRES_VERSION` to test against another release,
e.g. `POSTGRES_VERSION=13 packages/pg/script/test-server.sh`.

## On the host

Steps to install and configure Postgres on Mac for developing against locally

1. Install homebrew
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ If your change involves breaking backwards compatibility please please point tha
1. Clone the repo
2. Ensure you have installed libpq-dev in your system (the native bindings are built in the test process)
3. From your workspace root run `yarn` and then `yarn lerna bootstrap`
4. Ensure you have a PostgreSQL instance running with SSL enabled and an empty database for tests. _note: you can skip the tests requring SSL by setting the environment variable `PGTESTNOSSL=1` if you're not changing any SSL related code_.
5. Ensure you have the proper environment variables configured for connecting to your postgres instance. Using the standard `PG*` environment variables like `PGUSER` and `PGPASSWORD` etc...
4. Ensure you have a PostgreSQL instance running with SSL enabled and an empty database for tests. Running `packages/pg/script/test-server.sh` starts one in a container, or see [LOCAL_DEV.md](./LOCAL_DEV.md) to configure your own. _note: you can skip the tests requring SSL by setting the environment variable `PGTESTNOSSL=1` if you're not changing any SSL related code_.
5. Ensure you have the proper environment variables configured for connecting to your postgres instance. Using the standard `PG*` environment variables like `PGUSER` and `PGPASSWORD` etc... The script in step 4 prints the ones the SCRAM tests need.
6. Run `yarn test` to run all the tests.

## Troubleshooting and FAQ
Expand Down
34 changes: 30 additions & 4 deletions docs/pages/features/ssl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,40 @@ Direct negotiation requests the `postgresql` ALPN protocol during the TLS handsh

## 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:
Channel binding ties SCRAM authentication to the TLS connection it takes place on. A server that does not hold both the private key for the certificate it presented _and_ the user's password hash cannot successfully authenticate with channel binding. Channel binding thus authenticates the server to the client even when the certificate itself is not verified. It requires a TLS connection and the SCRAM-SHA-256-PLUS authentication mechanism, which PostgreSQL 11 and newer offer over SSL for roles whose password is stored using `scram-sha-256`.

The `channel_binding` option takes the same three values as [the corresponding libpq parameter](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING):

- `'require'` refuses to authenticate at all unless the exchange is bound to the server's certificate
- `'prefer'` (now the default) uses channel binding whenever the server offers it, and authenticates without it otherwise
- `'disable'` never uses it

```js
const client = new Client({ ...config, enableChannelBinding: true})
const client = new Client({ ...config, channel_binding: 'require' })
```

It can also be supplied via a connection string, or the `PGCHANNELBINDING` environment variable. For example:

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

or
The earlier, boolean `enableChannelBinding` option remains available (`true` maps to `'prefer'` and `false` to `'disable'`), but should be considered deprecated.

## Requiring an authentication method

A server chooses which authentication method to ask the client for, so a server subject to an MITM attack can ask for a weaker method than expected. For example, it might request the user's plaintext password instead of SCRAM, or treat the client as authenticated without asking for anything at all. The `require_auth` option pins down what authentication methods we will accept from the server, exactly like the [libpq parameter of the same name](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-REQUIRE-AUTH):

```js
const pool = new Pool({ ...config, enableChannelBinding: true})
const client = new Client({ ...config, require_auth: 'scram-sha-256' })
```

The value is a comma-separated list of methods, any of which the server may use, or a list in which every entry is negated with `!`, naming methods it may not use. `none` stands for a connection where the server asks for nothing (so `require_auth: 'none'` only accepts access without authentication, and `require_auth: '!none'` insists simply that the server authenticates us somehow).

The methods libpq recognizes are `password`, `md5`, `scram-sha-256`, `gss`, `sspi` and `oauth`. Of these, node-postgres implements the first three. Naming only methods it cannot perform is an error rather than a connection that could never succeed. The native client passes the setting to libpq, which performs the full set (according to its build settings). It can equally be set in a connection string or with the `PGREQUIREAUTH` environment variable.

Setting `channel_binding: 'require'` implies `require_auth: 'scram-sha-256'`, since no other method can be bound. Combining it with a `require_auth` that rules SCRAM out is an error.

Note that both `channel_binding` and `require_auth` keep libpq's snake_cased spelling. A camelCased `channelBinding` or `requireAuth` is not recognized, and throws an error rather than connecting without the protection requested.
2 changes: 2 additions & 0 deletions packages/pg-connection-string/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ Query parameters follow a `?` character, including the following special query p
* `sslcert=<filename>` - reads data from the given file and includes the result as `ssl.cert`
* `sslkey=<filename>` - reads data from the given file and includes the result as `ssl.key`
* `sslrootcert=<filename>` - reads data from the given file and includes the result as `ssl.ca`
* `channel_binding=<disable|prefer|require>` - sets the `channel_binding` property, which the client acts on. As in libpq, these three values are the only ones accepted.
* `require_auth=<method[,method...]>` - sets the `require_auth` property, which the client acts on, naming the authentication method(s) the server is allowed to ask for. The value is passed through unchanged but validated by the client, which accepts libpq's methods (`password`, `md5`, `gss`, `sspi`, `scram-sha-256`, `oauth` and `none`). Optionally, all methods may be negated with `!`, which makes this a block-list instead of an allow-list.

A bare relative URL, such as `salesdata`, will indicate a database name while leaving other properties empty.

Expand Down
11 changes: 11 additions & 0 deletions packages/pg-connection-string/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import { ClientConfig } from 'pg'

export function parse(connectionString: string, options?: Options): ConnectionOptions

// Use of SCRAM channel binding, as libpq's channel_binding parameter defines it
export type ChannelBinding = 'disable' | 'prefer' | 'require'

export interface Options {
// Use libpq semantics when interpreting the connection string
useLibpqCompat?: boolean
// The channel binding setting held by the caller, for cases where it was not
// given in the connection string. A value of 'require' suppresses the sslmode
// deprecation warning, since the server is then authenticated by the binding.
channelBinding?: ChannelBinding
}

interface SSLConfig {
Expand All @@ -23,6 +30,10 @@ export interface ConnectionOptions {
client_encoding?: string
ssl?: boolean | string | SSLConfig
sslnegotiation?: 'postgres' | 'direct'
channel_binding?: ChannelBinding
// The authentication method(s) the server may ask for, as libpq's require_auth
// parameter defines them: a comma-separated list, optionally negated with '!'
require_auth?: string

application_name?: string
fallback_application_name?: string
Expand Down
10 changes: 9 additions & 1 deletion packages/pg-connection-string/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ function parse(str, options = {}) {
}
}
} else {
// A required channel binding authenticates the server to the client, so none
// of the weaker libpq sslmode guarantees warned about below can be exploited.
// Only the connection string is visible here, so the caller passes on any
// setting it holds itself, and the connection string takes precedence.
// Note: options.channelBinding may be boolean rather than string, but testing
// against `"require"` remains correct (neither boolean value means the same).
const channelBinding = config.channel_binding || options.channelBinding

switch (config.sslmode) {
case 'disable': {
config.ssl = false
Expand All @@ -145,7 +153,7 @@ function parse(str, options = {}) {
case 'require':
case 'verify-ca':
case 'verify-full': {
if (config.sslmode !== 'verify-full') {
if (config.sslmode !== 'verify-full' && channelBinding !== 'require') {
deprecatedSslModeWarning(config.sslmode)
}
break
Expand Down
67 changes: 67 additions & 0 deletions packages/pg-connection-string/test/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const expect = chai.expect
chai.should()

import { parse } from '../'
import type { Options } from '../'

describe('parse', function () {
it('using connection string in client constructor', function () {
Expand Down Expand Up @@ -454,6 +455,72 @@ describe('parse', function () {
}).to.throw()
})

describe('channel binding', function () {
// The sslmode deprecation warning is emitted at most once per process, so
// each case asserts against a freshly loaded copy of the module.
function warningsFrom(connectionString: string, options?: Options): string[] {
const modulePath = require.resolve('../index.js')
delete require.cache[modulePath]
const freshParse = require(modulePath).parse as typeof parse
const warnings: string[] = []
const emitWarning = process.emitWarning
process.emitWarning = ((warning: string | Error) => {
warnings.push(String(warning))
}) as typeof process.emitWarning
try {
freshParse(connectionString, options)
} finally {
process.emitWarning = emitWarning
delete require.cache[modulePath]
}
return warnings
}

it('configuration parameter channel_binding=require', function () {
const subject = parse('pg:///?channel_binding=require')
subject.channel_binding?.should.equal('require')
})

it('configuration parameter channel_binding=prefer', function () {
const subject = parse('pg:///?channel_binding=prefer')
subject.channel_binding?.should.equal('prefer')
})

it('configuration parameter channel_binding=disable', function () {
const subject = parse('pg:///?channel_binding=disable')
subject.channel_binding?.should.equal('disable')
})

it('channel_binding does not change the ssl configuration', function () {
const subject = parse('pg:///?sslmode=require&channel_binding=require')
subject.ssl?.should.eql({})
})

it('channel_binding=require suppresses the sslmode deprecation warning', function () {
for (const sslmode of ['prefer', 'require', 'verify-ca']) {
warningsFrom(`pg:///?sslmode=${sslmode}&channel_binding=require`).should.eql([])
}
})

it('other channel_binding values leave the sslmode deprecation warning in place', function () {
for (const channelBinding of ['', 'prefer', 'disable']) {
const warnings = warningsFrom(`pg:///?sslmode=require&channel_binding=${channelBinding}`)
warnings.should.have.length(1)
warnings[0].should.match(/SECURITY WARNING/)
}
})

it('channelBinding option suppresses the warning when the connection string omits it', function () {
warningsFrom('pg:///?sslmode=require', { channelBinding: 'require' }).should.eql([])
})

it('a channel_binding connection string parameter takes precedence over the option', function () {
const warnings = warningsFrom('pg:///?sslmode=require&channel_binding=disable', { channelBinding: 'require' })
warnings.should.have.length(1)
warnings[0].should.match(/SECURITY WARNING/)
})
})

it('allow other params like max, ...', function () {
const subject = parse('pg://myhost/db?max=18&min=4')
subject.max?.should.equal('18')
Expand Down
36 changes: 36 additions & 0 deletions packages/pg/lib/channel-binding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict'

// Support for libpq's channel_binding parameter, which says whether SCRAM authentication
// has to be bound to the server's certificate:
// https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING

const defaults = require('./defaults')

const channelBindingLevels = ['disable', 'prefer', 'require']

// Accepts the levels libpq's channel_binding parameter defines, plus the booleans that
// pg's original enableChannelBinding option took. Any other non-string keeps its
// historical truthiness, so previously working configs keep working. A string that is not
// a level is refused rather than read as the weakest one that resembles it.
const normalizeChannelBinding = function (value) {
if (typeof value !== 'string') {
return value ? 'prefer' : 'disable'
}
if (!channelBindingLevels.includes(value)) {
throw new Error(
`Invalid channel_binding value: "${value}". Valid values are "disable", "prefer" and "require" (or a boolean).`
)
}
return value
}

// channel_binding, being libpq's own spelling, wins over the older
// enableChannelBinding option, then the environment, then the default.
const resolveChannelBinding = function (channelBinding, enableChannelBinding) {
const value = [channelBinding, enableChannelBinding, process.env.PGCHANNELBINDING, defaults.channel_binding].find(
(candidate) => candidate !== undefined && candidate !== null
)
return normalizeChannelBinding(value)
}

module.exports = { channelBindingLevels, normalizeChannelBinding, resolveChannelBinding }
Loading
Loading