From 4eb0b86ca68d3354ec16136c9f6a1b00ee866de8 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 11 May 2026 18:14:39 +0200 Subject: [PATCH 1/9] fix: apply SASLprep (RFC 4013) to passwords before SCRAM-SHA-256 PBKDF2 `pg`'s SCRAM-SHA-256 client passes the raw password into PBKDF2 with no normalization, while PostgreSQL's server (and libpq) apply SASLprep (B.1 mapping -> NFKC -> prohibition + bidi check) when computing the stored verifier. Passwords whose NFKC form differs from themselves (e.g. containing U+00A8 dieresis, U+2011 non-breaking hyphen, U+00BC vulgar one quarter, NBSP, soft hyphen) authenticate with psql/libpq but fail against pg with `28P01`. Wire `@mongodb-js/saslprep` (the maintained fork used by mongodb's official Node driver) into `continueSession` before `crypto.deriveKey`, with a try/catch fallback to the raw password on prohibited / bidi violations to match `libpq`'s `pg_saslprep` behavior. Also adds: - Unit tests covering the soft-hyphen B.1 mapping equivalence, the Roman-numeral-IX NFKC asymmetry, the prohibited-char fallback, and a deterministic snapshot for the original bug-report password. - A gated integration test block (SCRAM_TEST_PGUSER_UNICODE / SCRAM_TEST_PGPASSWORD_UNICODE) covering raw + NFKC-equivalent + wrong password. - A `scram_unicode_test` role (password `U&'IX-\2168'`) provisioned in CI plus matching env vars so the new integration tests run on every Node version. - A Cloudflare Workers regression guard that exercises `sasl.continueSession` to ensure `@mongodb-js/saslprep` resolves cleanly under workerd. - A `pg@8.21.0` CHANGELOG entry. --- .github/workflows/ci.yml | 10 ++- CHANGELOG.md | 4 + packages/pg/lib/crypto/sasl.js | 19 ++++- packages/pg/package.json | 1 + packages/pg/test/cloudflare/vitest-cf.test.ts | 16 ++++ .../integration/client/sasl-scram-tests.js | 79 +++++++++++++++++++ .../pg/test/unit/client/sasl-scram-tests.js | 63 +++++++++++++++ yarn.lock | 50 +++++++++++- 8 files changed, 237 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d90e474c..05bc5b66b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,13 @@ jobs: PGTESTNOSSL: 'true' SCRAM_TEST_PGUSER: scram_test SCRAM_TEST_PGPASSWORD: test4scram + SCRAM_TEST_PGUSER_UNICODE: scram_unicode_test + # Raw form of a password whose NFKC normalization differs from itself. + # U+2168 (ROMAN NUMERAL IX) decomposes to ASCII "IX" under NFKC; the + # server stores the verifier from the SASLprep-normalized form, so the + # client must apply SASLprep too. This is the regression check for the + # RFC 4013 fix in packages/pg/lib/crypto/sasl.js. + SCRAM_TEST_PGPASSWORD_UNICODE: "IX-\u2168" steps: - name: Show OS run: | @@ -63,7 +70,8 @@ jobs: - run: | psql \ -c "SET password_encryption = 'scram-sha-256'" \ - -c "CREATE ROLE scram_test LOGIN PASSWORD 'test4scram'" + -c "CREATE ROLE scram_test LOGIN PASSWORD 'test4scram'" \ + -c "CREATE ROLE scram_unicode_test LOGIN PASSWORD U&'IX-\2168'" - uses: actions/checkout@v4 with: persist-credentials: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d167ceef..29fd33a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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.21.0 + +- SCRAM-SHA-256 now applies [SASLprep (RFC 4013)](https://datatracker.ietf.org/doc/html/rfc4013) to passwords before PBKDF2, matching `libpq` and the PostgreSQL server. Non-ASCII passwords whose NFKC form differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, or soft hyphen) now authenticate successfully instead of failing with `28P01`. Powered by [`@mongodb-js/saslprep`](https://www.npmjs.com/package/@mongodb-js/saslprep). When SASLprep rejects a password (prohibited code points, bidi violation), the raw bytes are used as a fallback, matching `libpq`'s `pg_saslprep`. + ## pg@8.20.0 - Add [onConnect](https://github.com/brianc/node-postgres/pull/3620) callback to pg.Pool constructor options allowing for async initialization of newly created & connected pooled clients. diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 47b77610c..837cd46ad 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,5 +1,6 @@ 'use strict' const crypto = require('./utils') +const saslprep = require('@mongodb-js/saslprep') const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') function startSession(mechanisms, stream) { @@ -69,8 +70,24 @@ async function continueSession(session, password, serverData, stream) { const clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce const authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof + // Per RFC 5802 §2.2 and RFC 4013, the password must be processed through + // SASLprep (mapping + NFKC + prohibition + bidi) before being fed into + // PBKDF2. PostgreSQL's server applies SASLprep when computing the stored + // verifier (and libpq applies it client-side), so passwords whose NFKC form + // differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, soft + // hyphen) would otherwise authenticate against psql/libpq but not against us. + // If SASLprep rejects the password (prohibited code points, bidi violation), + // fall back to the raw password — this matches libpq's `pg_saslprep` + // behavior so legacy roles created with lenient encoders keep working. + let preparedPassword + try { + preparedPassword = saslprep(password) + } catch { + preparedPassword = password + } + const saltBytes = Buffer.from(sv.salt, 'base64') - const saltedPassword = await crypto.deriveKey(password, saltBytes, sv.iteration) + const saltedPassword = await crypto.deriveKey(preparedPassword, saltBytes, sv.iteration) const clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key') const storedKey = await crypto.sha256(clientKey) const clientSignature = await crypto.hmacSha256(storedKey, authMessage) diff --git a/packages/pg/package.json b/packages/pg/package.json index d14e448d6..112ffd78c 100644 --- a/packages/pg/package.json +++ b/packages/pg/package.json @@ -32,6 +32,7 @@ "./lib/*.js": "./lib/*.js" }, "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", diff --git a/packages/pg/test/cloudflare/vitest-cf.test.ts b/packages/pg/test/cloudflare/vitest-cf.test.ts index c71e01961..d01260c7f 100644 --- a/packages/pg/test/cloudflare/vitest-cf.test.ts +++ b/packages/pg/test/cloudflare/vitest-cf.test.ts @@ -1,4 +1,5 @@ import { Pool } from 'pg' +import sasl from 'pg/lib/crypto/sasl' import { test } from 'vitest' import assert from 'assert' @@ -8,3 +9,18 @@ test('default', async () => { assert(result.rows[0].name === 'cloudflare') pool.end() }) + +// Regression guard: confirms `@mongodb-js/saslprep` (a transitive dep added to +// fix RFC 4013 compliance for SCRAM-SHA-256) resolves and runs under workerd. +// If the dep ever ships a Node-only API or breaks worker compatibility, this +// fails with a module-loading or runtime error instead of silently falling back +// to non-prepped passwords. +test('SASLprep is engaged on the SCRAM path under workerd', async () => { + const session = { message: 'SASLInitialResponse', clientNonce: 'a' } + const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' } + // 'I\u00ADX' (with soft hyphen, B.1 mapped-to-nothing) must SASLprep to 'IX' + await sasl.continueSession(session, 'I\u00ADX', 'r=ab,s=abcd,i=1') + await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1') + assert.strictEqual(session.serverSignature, sessionRef.serverSignature) + assert.strictEqual(session.response, sessionRef.response) +}) diff --git a/packages/pg/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 85bf2cd34..bf1dfcb0d 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -108,3 +108,82 @@ suite.test('sasl/scram fails when password is empty', async () => { ) assert.ok(usingSasl, 'Should be using SASL for authentication') }) + +/** + * SASLprep regression coverage. RFC 5802 / RFC 4013 require the SCRAM client + * to normalize the password (B.1 mapping → NFKC → prohibition + bidi check) + * before feeding it into PBKDF2. PostgreSQL's server applies the same + * SASLprep when computing the verifier, so any password whose NFKC form + * differs from the raw form would otherwise authenticate against psql/libpq + * but fail against pg with `28P01`. + * + * To exercise these tests, provision a role whose password contains an + * NFKC-asymmetric character. For example, in psql: + * + * SET password_encryption = 'scram-sha-256'; + * CREATE ROLE scram_unicode_test LOGIN PASSWORD U&'IX-\2168'; + * + * `\2168` is ROMAN NUMERAL IX; the server SASLprep-normalizes this to + * `IX-IX` when computing the verifier. Then export: + * + * SCRAM_TEST_PGUSER_UNICODE=scram_unicode_test + * SCRAM_TEST_PGPASSWORD_UNICODE='IX-\u2168' (i.e. the raw form) + * + * If either env var is unset the suite is skipped, matching the convention + * of the ASCII SCRAM block above. + */ +const unicodeConfig = { + user: process.env.SCRAM_TEST_PGUSER_UNICODE, + password: process.env.SCRAM_TEST_PGPASSWORD_UNICODE, + host: process.env.SCRAM_TEST_PGHOST, + port: process.env.SCRAM_TEST_PGPORT, + database: process.env.SCRAM_TEST_PGDATABASE, +} + +if (!unicodeConfig.user || !unicodeConfig.password) { + suite.test('skipping SCRAM unicode tests (missing env)', () => {}) +} else { + suite.test('sasl/scram authenticates a password requiring SASLprep (raw form)', async () => { + const client = new pg.Client(unicodeConfig) + let usingSasl = false + client.connection.once('authenticationSASL', () => { + usingSasl = true + }) + await client.connect() + assert.ok(usingSasl, 'Should be using SASL for authentication') + await client.end() + }) + + suite.test('sasl/scram authenticates the NFKC-equivalent ASCII form of the same password', async () => { + // The unicode password contains a codepoint that NFKC-decomposes to ASCII + // (e.g. U+2168 → "IX"). The server stored the verifier from the + // SASLprep'd ASCII form, so feeding the client the ASCII form directly + // must also authenticate. This proves that the prep step is symmetric: + // any NFKC-equivalent representation reaches the same PBKDF2 input. + const client = new pg.Client({ + ...unicodeConfig, + password: unicodeConfig.password.normalize('NFKC'), + }) + await client.connect() + await client.end() + }) + + suite.test('sasl/scram fails when unicode password is wrong', async () => { + const client = new pg.Client({ + ...unicodeConfig, + password: unicodeConfig.password + 'append-something-to-make-it-bad', + }) + let usingSasl = false + client.connection.once('authenticationSASL', () => { + usingSasl = true + }) + await assert.rejects( + () => client.connect(), + { + code: '28P01', + }, + 'Error code should be for a password error' + ) + assert.ok(usingSasl, 'Should be using SASL for authentication') + }) +} diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 2df0f1860..78758a435 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -204,6 +204,69 @@ suite.test('sasl/scram', function () { assert.equal(session.response, 'c=eSws,r=ab,p=YVTEOwOD7khu/NulscjFegHrZoTXJBFI/7L61AN9khc=') }) + suite.test('SASLprep maps mapped-to-nothing characters before PBKDF2 (RFC 4013 B.1)', async function () { + // Soft hyphen U+00AD is mapped to nothing by SASLprep, so 'I\u00ADX' + // must produce identical SCRAM output to 'IX'. This proves the prep + // step is engaged on the SCRAM derivation path. Without the fix the + // two would diverge and this assertion would fail. + const sessionPrepped = { message: 'SASLInitialResponse', clientNonce: 'a' } + const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' } + + await sasl.continueSession(sessionPrepped, 'I\u00ADX', 'r=ab,s=abcd,i=1') + await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1') + + assert.equal(sessionPrepped.serverSignature, sessionRef.serverSignature) + assert.equal(sessionPrepped.response, sessionRef.response) + }) + + suite.test('SASLprep NFKC-normalizes passwords before PBKDF2 (RFC 4013 §2.2)', async function () { + // ROMAN NUMERAL IX (U+2168) NFKC-decomposes to the ASCII letters 'IX'. + // PostgreSQL's server applies SASLprep when computing the verifier, so + // a role created with U+2168 is stored as if it were 'IX'. The client + // must do the same. + const sessionPrepped = { message: 'SASLInitialResponse', clientNonce: 'a' } + const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' } + + await sasl.continueSession(sessionPrepped, '\u2168', 'r=ab,s=abcd,i=1') + await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1') + + assert.equal(sessionPrepped.serverSignature, sessionRef.serverSignature) + assert.equal(sessionPrepped.response, sessionRef.response) + }) + + suite.test('falls back to raw password when SASLprep rejects prohibited code points', async function () { + // BEL (U+0007) is in C.2.1 (ASCII control), so SASLprep throws. libpq's + // pg_saslprep falls back to the raw password in this case to keep + // legacy roles working; we mirror that behavior. The deterministic + // snapshot ensures any future regression (e.g. removing the try/catch + // and letting the throw propagate, or substituting a different + // fallback value) is caught immediately. + const session = { message: 'SASLInitialResponse', clientNonce: 'a' } + + await sasl.continueSession(session, '\u0007abc', 'r=ab,s=abcd,i=1') + + assert.equal(session.message, 'SASLResponse') + assert.equal(session.serverSignature, 'ytJN8GA+9TeZpeS28ix+u0cwaIB7iFlWgpAsmy+MmP0=') + assert.equal(session.response, 'c=biws,r=ab,p=04HAPnY4K2UhwiD2RJtFw9sU81SLcas8B1Uqdqv8SeQ=') + }) + + suite.test('SASLprep handles the production-bug password (¨ ‑ ¼ smart-text autocorrect)', async function () { + // Captures the original bug report: a password containing characters + // that NFKC alters — U+00A8 (¨ DIAERESIS, → SP + COMBINING DIAERESIS), + // U+2011 (‑ NON-BREAKING HYPHEN, → U+2010 HYPHEN), U+00BC (¼ ONE + // QUARTER, → 1⁄4). These typically come from macOS/iOS smart-text + // autocorrect of `"`, `-`, `1/4`. Without SASLprep, the client and + // server compute different PBKDF2 inputs and authentication fails + // with 28P01. + const session = { message: 'SASLInitialResponse', clientNonce: 'a' } + + await sasl.continueSession(session, 'abcd123456789123456789\u00A8\u2011\u00BC###', 'r=ab,s=abcd,i=1') + + assert.equal(session.message, 'SASLResponse') + assert.equal(session.serverSignature, 'ZjuSuv1K2PH8wqCctZpXo8XZaXqT5BpOEhApVDRpX1U=') + assert.equal(session.response, 'c=biws,r=ab,p=w7yYtE6oy8mtvS0Eg3F/jUCmi7zA7+OZRHBXzd2BuFk=') + }) + suite.test('sets expected session data (SCRAM-SHA-256-PLUS)', async function () { const session = { message: 'SASLInitialResponse', diff --git a/yarn.lock b/yarn.lock index b221bd37a..2f5d0ba19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1725,6 +1725,13 @@ npmlog "^4.1.2" write-file-atomic "^2.3.0" +"@mongodb-js/saslprep@^1.4.11": + version "1.4.11" + resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz#3fae1bf22a6e485ea42d26e46d06e49c8eadb86b" + integrity sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA== + dependencies: + sparse-bitfield "^3.0.3" + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz" @@ -6211,6 +6218,11 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +memory-pager@^1.0.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" + integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + meow@^3.3.0: version "3.7.0" resolved "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz" @@ -8333,6 +8345,13 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +sparse-bitfield@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" + integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== + dependencies: + memory-pager "^1.0.2" + spawn-wrap@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" @@ -8487,7 +8506,7 @@ stream-spec@~0.3.5: dependencies: macgyver "~1.10" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8522,6 +8541,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -8561,7 +8589,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8589,6 +8617,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9506,7 +9541,7 @@ wrangler@^3.x: fsevents "~2.3.2" sharp "^0.33.5" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9533,6 +9568,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From c8d2e513dbd54b9e904036484011ade783b0cdc5 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 12 May 2026 15:00:55 +0200 Subject: [PATCH 2/9] fix: inline SASLprep, drop @mongodb-js/saslprep dependency Per review feedback on #3669: ship the SASLprep step as a small in-tree function instead of pulling a runtime dep with an unpinned transitive. The function performs only the three byte-changing steps from RFC 4013 (Table C.1.2 -> SPACE, Table B.1 -> empty, NFKC) and skips the prohibition (RFC 4013 section 2.3) and bidi (RFC 3454 section 6) checks, since libpq is forgiving on those paths and Postgres's own SASLprep is similarly lenient. Removes the try/catch fallback (no code path throws). The deterministic snapshot tests stay byte-for-byte valid because none of them touch U+200B, the only edge case where the inline impl diverges from `@mongodb-js/saslprep`. RFC 3454 places U+200B in Table B.1 (mapped to nothing); the dep maps it to SPACE. PostgreSQL's saslprep.c follows the RFC, so the inline impl matches libpq more closely on that codepoint. The B.1 unit-test rename ("passes ASCII control characters through normalization unchanged") keeps the same snapshot bytes since BEL is unchanged by all three steps. Co-authored-by: charmander --- CHANGELOG.md | 2 +- packages/pg/lib/crypto/sasl.js | 51 ++++++++++++------- packages/pg/package.json | 1 - .../pg/test/unit/client/sasl-scram-tests.js | 14 ++--- yarn.lock | 19 ------- 5 files changed, 41 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fd33a31..44d5d81a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ We do not include break-fix version release in this file. ## pg@8.21.0 -- SCRAM-SHA-256 now applies [SASLprep (RFC 4013)](https://datatracker.ietf.org/doc/html/rfc4013) to passwords before PBKDF2, matching `libpq` and the PostgreSQL server. Non-ASCII passwords whose NFKC form differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, or soft hyphen) now authenticate successfully instead of failing with `28P01`. Powered by [`@mongodb-js/saslprep`](https://www.npmjs.com/package/@mongodb-js/saslprep). When SASLprep rejects a password (prohibited code points, bidi violation), the raw bytes are used as a fallback, matching `libpq`'s `pg_saslprep`. +- SCRAM-SHA-256 now applies [SASLprep (RFC 4013)](https://datatracker.ietf.org/doc/html/rfc4013) to passwords before PBKDF2, matching `libpq` and the PostgreSQL server. Non-ASCII passwords whose NFKC form differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, or soft hyphen — typical macOS / iOS smart-text autocorrect output) now authenticate successfully instead of failing with `28P01`. The implementation is a small in-tree function performing the three byte-changing steps (RFC 3454 Table C.1.2 → SPACE, Table B.1 → empty, NFKC); prohibition (RFC 4013 §2.3) and bidi (RFC 3454 §6) checks are intentionally omitted to match `libpq`'s lenient behavior on the byte-content path real users hit. ## pg@8.20.0 diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 837cd46ad..57120f0f3 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,8 +1,39 @@ 'use strict' const crypto = require('./utils') -const saslprep = require('@mongodb-js/saslprep') const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') +// SASLprep (RFC 4013) — minimal in-tree implementation. +// +// Per RFC 5802 §2.2, the SCRAM-SHA-256 client must normalize the password via +// SASLprep before feeding it into PBKDF2. PostgreSQL's server applies the same +// SASLprep when computing the stored verifier, and libpq does the same client +// side, so passwords whose NFKC form differs from the raw form (e.g. +// containing `¨`, `‑`, `¼`, NBSP, or soft hyphen — typical macOS / iOS +// smart-text autocorrect output) would otherwise authenticate against +// psql/libpq but fail against pg with `28P01`. +// +// We deliberately implement only the three steps that change the byte content: +// 1. RFC 3454 Table C.1.2 (non-ASCII space) → U+0020 SPACE. +// 2. RFC 3454 Table B.1 (commonly mapped to nothing) → empty. +// 3. NFKC normalization. +// We skip the prohibition (RFC 4013 §2.3) and bidi (RFC 3454 §6) checks. +// libpq is forgiving on those paths and Postgres's own SASLprep matches that +// leniency for legacy roles, so omitting the rejection logic keeps existing +// roles working without adding complexity. +function saslprep(password) { + // RFC 3454 Table C.1.2 — non-ASCII space characters, mapped to U+0020. + // prettier-ignore + const nonAsciiSpace = /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g + // RFC 3454 Table B.1 — "commonly mapped to nothing". The set intentionally + // contains zero-width joiners and variation selectors — the very characters + // ESLint's no-misleading-character-class warns about — because they combine + // with their neighbors and the RFC strips them for that reason. + // prettier-ignore + // eslint-disable-next-line no-misleading-character-class + const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200B\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g + return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC') +} + function startSession(mechanisms, stream) { const candidates = ['SCRAM-SHA-256'] if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first @@ -70,24 +101,8 @@ async function continueSession(session, password, serverData, stream) { const clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce const authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof - // Per RFC 5802 §2.2 and RFC 4013, the password must be processed through - // SASLprep (mapping + NFKC + prohibition + bidi) before being fed into - // PBKDF2. PostgreSQL's server applies SASLprep when computing the stored - // verifier (and libpq applies it client-side), so passwords whose NFKC form - // differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, soft - // hyphen) would otherwise authenticate against psql/libpq but not against us. - // If SASLprep rejects the password (prohibited code points, bidi violation), - // fall back to the raw password — this matches libpq's `pg_saslprep` - // behavior so legacy roles created with lenient encoders keep working. - let preparedPassword - try { - preparedPassword = saslprep(password) - } catch { - preparedPassword = password - } - const saltBytes = Buffer.from(sv.salt, 'base64') - const saltedPassword = await crypto.deriveKey(preparedPassword, saltBytes, sv.iteration) + const saltedPassword = await crypto.deriveKey(saslprep(password), saltBytes, sv.iteration) const clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key') const storedKey = await crypto.sha256(clientKey) const clientSignature = await crypto.hmacSha256(storedKey, authMessage) diff --git a/packages/pg/package.json b/packages/pg/package.json index 112ffd78c..d14e448d6 100644 --- a/packages/pg/package.json +++ b/packages/pg/package.json @@ -32,7 +32,6 @@ "./lib/*.js": "./lib/*.js" }, "dependencies": { - "@mongodb-js/saslprep": "^1.4.11", "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 78758a435..a58495487 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -234,13 +234,13 @@ suite.test('sasl/scram', function () { assert.equal(sessionPrepped.response, sessionRef.response) }) - suite.test('falls back to raw password when SASLprep rejects prohibited code points', async function () { - // BEL (U+0007) is in C.2.1 (ASCII control), so SASLprep throws. libpq's - // pg_saslprep falls back to the raw password in this case to keep - // legacy roles working; we mirror that behavior. The deterministic - // snapshot ensures any future regression (e.g. removing the try/catch - // and letting the throw propagate, or substituting a different - // fallback value) is caught immediately. + suite.test('passes ASCII control characters through normalization unchanged', async function () { + // BEL (U+0007) is an ASCII control character. The minimal SASLprep + // implementation (B.1 mapping → C.1.2 mapping → NFKC) is the identity + // on ASCII control codes, so the bytes fed to PBKDF2 are exactly the + // raw password. We snapshot the resulting SCRAM output as a regression + // guard: if anyone ever swaps the order of operations, removes the + // NFKC step, or accidentally strips ASCII bytes, this assertion trips. const session = { message: 'SASLInitialResponse', clientNonce: 'a' } await sasl.continueSession(session, '\u0007abc', 'r=ab,s=abcd,i=1') diff --git a/yarn.lock b/yarn.lock index 2f5d0ba19..33bceb55c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1725,13 +1725,6 @@ npmlog "^4.1.2" write-file-atomic "^2.3.0" -"@mongodb-js/saslprep@^1.4.11": - version "1.4.11" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz#3fae1bf22a6e485ea42d26e46d06e49c8eadb86b" - integrity sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA== - dependencies: - sparse-bitfield "^3.0.3" - "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz" @@ -6218,11 +6211,6 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -memory-pager@^1.0.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" - integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== - meow@^3.3.0: version "3.7.0" resolved "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz" @@ -8345,13 +8333,6 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -sparse-bitfield@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" - integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== - dependencies: - memory-pager "^1.0.2" - spawn-wrap@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" From 95835835322d609548da7f81c4f2aefebe3714a7 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 07:48:51 -0700 Subject: [PATCH 3/9] Revert unrelated no-op changes to yarn.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit now that the associated dependency isn’t being added. --- yarn.lock | 31 +++---------------------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/yarn.lock b/yarn.lock index 33bceb55c..b221bd37a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8487,7 +8487,7 @@ stream-spec@~0.3.5: dependencies: macgyver "~1.10" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8522,15 +8522,6 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -8570,7 +8561,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8598,13 +8589,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9522,7 +9506,7 @@ wrangler@^3.x: fsevents "~2.3.2" sharp "^0.33.5" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9549,15 +9533,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 74f6d01c66958e73a9527e5bd063c7bc24ee9569 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 08:12:43 -0700 Subject: [PATCH 4/9] cleanup: Allow Prettier to format some lines --- packages/pg/lib/crypto/sasl.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 57120f0f3..2009d44b8 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -22,15 +22,15 @@ const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') // roles working without adding complexity. function saslprep(password) { // RFC 3454 Table C.1.2 — non-ASCII space characters, mapped to U+0020. - // prettier-ignore - const nonAsciiSpace = /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g + const nonAsciiSpace = + /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g // RFC 3454 Table B.1 — "commonly mapped to nothing". The set intentionally // contains zero-width joiners and variation selectors — the very characters // ESLint's no-misleading-character-class warns about — because they combine // with their neighbors and the RFC strips them for that reason. - // prettier-ignore - // eslint-disable-next-line no-misleading-character-class - const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200B\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g + const mappedToNothing = + // eslint-disable-next-line no-misleading-character-class + /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200B\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC') } From b026faeec2c2a7988c959a306f1a1bba951e4f6f Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 08:13:42 -0700 Subject: [PATCH 5/9] cleanup: Remove changelog entry for unreleased pg version normally added as part of the release process --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d5d81a5..8d167ceef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,6 @@ 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.21.0 - -- SCRAM-SHA-256 now applies [SASLprep (RFC 4013)](https://datatracker.ietf.org/doc/html/rfc4013) to passwords before PBKDF2, matching `libpq` and the PostgreSQL server. Non-ASCII passwords whose NFKC form differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, or soft hyphen — typical macOS / iOS smart-text autocorrect output) now authenticate successfully instead of failing with `28P01`. The implementation is a small in-tree function performing the three byte-changing steps (RFC 3454 Table C.1.2 → SPACE, Table B.1 → empty, NFKC); prohibition (RFC 4013 §2.3) and bidi (RFC 3454 §6) checks are intentionally omitted to match `libpq`'s lenient behavior on the byte-content path real users hit. - ## pg@8.20.0 - Add [onConnect](https://github.com/brianc/node-postgres/pull/3620) callback to pg.Pool constructor options allowing for async initialization of newly created & connected pooled clients. From 4403def7b7b64af028ed62cb1ea3ed2cdd4d76f9 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 12 May 2026 17:48:24 +0200 Subject: [PATCH 6/9] refactor: Simplify comments in sasl.js and remove unused test cases Updated comments in sasl.js to clarify the password normalization process and removed redundant test cases from vitest-cf.test.ts, streamlining the codebase. --- packages/pg/lib/crypto/sasl.js | 6 ++---- packages/pg/test/cloudflare/vitest-cf.test.ts | 16 ---------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 2009d44b8..2e8c33322 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -7,10 +7,8 @@ const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') // Per RFC 5802 §2.2, the SCRAM-SHA-256 client must normalize the password via // SASLprep before feeding it into PBKDF2. PostgreSQL's server applies the same // SASLprep when computing the stored verifier, and libpq does the same client -// side, so passwords whose NFKC form differs from the raw form (e.g. -// containing `¨`, `‑`, `¼`, NBSP, or soft hyphen — typical macOS / iOS -// smart-text autocorrect output) would otherwise authenticate against -// psql/libpq but fail against pg with `28P01`. +// side, so passwords whose NFKC form differs from the raw form +// would otherwise authenticate against psql/libpq but fail against pg with `28P01`. // // We deliberately implement only the three steps that change the byte content: // 1. RFC 3454 Table C.1.2 (non-ASCII space) → U+0020 SPACE. diff --git a/packages/pg/test/cloudflare/vitest-cf.test.ts b/packages/pg/test/cloudflare/vitest-cf.test.ts index d01260c7f..c71e01961 100644 --- a/packages/pg/test/cloudflare/vitest-cf.test.ts +++ b/packages/pg/test/cloudflare/vitest-cf.test.ts @@ -1,5 +1,4 @@ import { Pool } from 'pg' -import sasl from 'pg/lib/crypto/sasl' import { test } from 'vitest' import assert from 'assert' @@ -9,18 +8,3 @@ test('default', async () => { assert(result.rows[0].name === 'cloudflare') pool.end() }) - -// Regression guard: confirms `@mongodb-js/saslprep` (a transitive dep added to -// fix RFC 4013 compliance for SCRAM-SHA-256) resolves and runs under workerd. -// If the dep ever ships a Node-only API or breaks worker compatibility, this -// fails with a module-loading or runtime error instead of silently falling back -// to non-prepped passwords. -test('SASLprep is engaged on the SCRAM path under workerd', async () => { - const session = { message: 'SASLInitialResponse', clientNonce: 'a' } - const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' } - // 'I\u00ADX' (with soft hyphen, B.1 mapped-to-nothing) must SASLprep to 'IX' - await sasl.continueSession(session, 'I\u00ADX', 'r=ab,s=abcd,i=1') - await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1') - assert.strictEqual(session.serverSignature, sessionRef.serverSignature) - assert.strictEqual(session.response, sessionRef.response) -}) From f7fe136f97bde1c87e8e250fb551fe2514ec4454 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 09:55:40 -0700 Subject: [PATCH 7/9] Remove redundant NFKC-only SASLprep test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed in pull request comments that the “macOS/iOS” thing was an AI inventing an unneeded justification, and NFKC is already covered by another test. --- .../pg/test/unit/client/sasl-scram-tests.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index a58495487..5a07d7e4f 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -250,23 +250,6 @@ suite.test('sasl/scram', function () { assert.equal(session.response, 'c=biws,r=ab,p=04HAPnY4K2UhwiD2RJtFw9sU81SLcas8B1Uqdqv8SeQ=') }) - suite.test('SASLprep handles the production-bug password (¨ ‑ ¼ smart-text autocorrect)', async function () { - // Captures the original bug report: a password containing characters - // that NFKC alters — U+00A8 (¨ DIAERESIS, → SP + COMBINING DIAERESIS), - // U+2011 (‑ NON-BREAKING HYPHEN, → U+2010 HYPHEN), U+00BC (¼ ONE - // QUARTER, → 1⁄4). These typically come from macOS/iOS smart-text - // autocorrect of `"`, `-`, `1/4`. Without SASLprep, the client and - // server compute different PBKDF2 inputs and authentication fails - // with 28P01. - const session = { message: 'SASLInitialResponse', clientNonce: 'a' } - - await sasl.continueSession(session, 'abcd123456789123456789\u00A8\u2011\u00BC###', 'r=ab,s=abcd,i=1') - - assert.equal(session.message, 'SASLResponse') - assert.equal(session.serverSignature, 'ZjuSuv1K2PH8wqCctZpXo8XZaXqT5BpOEhApVDRpX1U=') - assert.equal(session.response, 'c=biws,r=ab,p=w7yYtE6oy8mtvS0Eg3F/jUCmi7zA7+OZRHBXzd2BuFk=') - }) - suite.test('sets expected session data (SCRAM-SHA-256-PLUS)', async function () { const session = { message: 'SASLInitialResponse', From 0b80c90fa30846caf9241ca91754ec1ef0378173 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 10:37:31 -0700 Subject: [PATCH 8/9] fix: SASLprep zero-width space the same way PostgreSQL does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As mentioned in the test comment, RFC 3454 defines appendix B for mapping tables and appendix C for prohibition tables. RFC 4013 SASLprep is probably misusing that list of non-ASCII spaces, and says nothing about the overlap. (At least it’s obsoleted.) --- packages/pg/lib/crypto/sasl.js | 4 ++-- packages/pg/test/unit/client/sasl-scram-tests.js | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 2e8c33322..b7a993573 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -21,14 +21,14 @@ const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') function saslprep(password) { // RFC 3454 Table C.1.2 — non-ASCII space characters, mapped to U+0020. const nonAsciiSpace = - /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g + /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u200B\u202F\u205F\u3000]/g // RFC 3454 Table B.1 — "commonly mapped to nothing". The set intentionally // contains zero-width joiners and variation selectors — the very characters // ESLint's no-misleading-character-class warns about — because they combine // with their neighbors and the RFC strips them for that reason. const mappedToNothing = // eslint-disable-next-line no-misleading-character-class - /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200B\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g + /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC') } diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 5a07d7e4f..832c20d8b 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -204,7 +204,19 @@ suite.test('sasl/scram', function () { assert.equal(session.response, 'c=eSws,r=ab,p=YVTEOwOD7khu/NulscjFegHrZoTXJBFI/7L61AN9khc=') }) - suite.test('SASLprep maps mapped-to-nothing characters before PBKDF2 (RFC 4013 B.1)', async function () { + suite.test('SASLprep maps non-ASCII space characters (RFC 3454 C.1.2) to U+0020 SPACE', async function () { + // SASLprep probably misuses the C.1.2 table; U+200B, in particular, is listed in both the C.1.2 and B.1 tables. We treat it as a space for compatibility with PostgreSQL. + const sessionPrepped = { message: 'SASLInitialResponse', clientNonce: 'a' } + const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' } + + await sasl.continueSession(sessionPrepped, '\u200bfoo\xa0bar', 'r=ab,s=abcd,i=1') + await sasl.continueSession(sessionRef, ' foo bar', 'r=ab,s=abcd,i=1') + + assert.equal(sessionPrepped.serverSignature, sessionRef.serverSignature) + assert.equal(sessionPrepped.response, sessionRef.response) + }) + + suite.test('SASLprep maps mapped-to-nothing characters before PBKDF2 (RFC 3454 B.1)', async function () { // Soft hyphen U+00AD is mapped to nothing by SASLprep, so 'I\u00ADX' // must produce identical SCRAM output to 'IX'. This proves the prep // step is engaged on the SCRAM derivation path. Without the fix the From 464ae2c8cb8442b55a3cfc29657d1e392bd138f5 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 10:46:16 -0700 Subject: [PATCH 9/9] cleanup: Simplify regex character classes with ranges --- packages/pg/lib/crypto/sasl.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index b7a993573..b0d07868c 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -20,15 +20,13 @@ const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') // roles working without adding complexity. function saslprep(password) { // RFC 3454 Table C.1.2 — non-ASCII space characters, mapped to U+0020. - const nonAsciiSpace = - /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u200B\u202F\u205F\u3000]/g + const nonAsciiSpace = /[\u00A0\u1680\u2000-\u200B\u202F\u205F\u3000]/g // RFC 3454 Table B.1 — "commonly mapped to nothing". The set intentionally // contains zero-width joiners and variation selectors — the very characters // ESLint's no-misleading-character-class warns about — because they combine // with their neighbors and the RFC strips them for that reason. - const mappedToNothing = - // eslint-disable-next-line no-misleading-character-class - /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g + // eslint-disable-next-line no-misleading-character-class + const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200C\u200D\u2060\uFE00-\uFE0F\uFEFF]/g return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC') }