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
18 changes: 17 additions & 1 deletion packages/pg/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ const queryQueueLengthDeprecationNotice = nodeUtils.deprecate(
'Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead.'
)

function coerceNumberOrDefault(value, defaultValue) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : defaultValue
}
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value)
return Number.isFinite(n) ? n : defaultValue
}
return defaultValue
}

class Client extends EventEmitter {
constructor(config) {
super()
Expand Down Expand Up @@ -74,6 +85,7 @@ class Client extends EventEmitter {
this._txStatus = null

this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered
this.scramMaxIterations = coerceNumberOrDefault(c.scramMaxIterations, sasl.DEFAULT_MAX_SCRAM_ITERATIONS)
this.connection =
c.connection ||
new Connection({
Expand Down Expand Up @@ -307,7 +319,11 @@ class Client extends EventEmitter {
_handleAuthSASL(msg) {
this._getPassword(() => {
try {
this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream)
this.saslSession = sasl.startSession(
msg.mechanisms,
this.enableChannelBinding && this.connection.stream,
this.scramMaxIterations
)
this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response)
} catch (err) {
this.connection.emit('error', err)
Expand Down
18 changes: 17 additions & 1 deletion packages/pg/lib/crypto/sasl.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ function saslprep(password) {
return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC')
}

function startSession(mechanisms, stream) {
const DEFAULT_MAX_SCRAM_ITERATIONS = 100000

function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) {
const candidates = ['SCRAM-SHA-256']
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first

Expand All @@ -53,6 +55,7 @@ function startSession(mechanisms, stream) {
clientNonce,
response: gs2Header + ',,n=*,r=' + clientNonce,
message: 'SASLInitialResponse',
scramMaxIterations,
}
}

Expand All @@ -78,6 +81,18 @@ async function continueSession(session, password, serverData, stream) {
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short')
}

const scramMaxIterations =
typeof session.scramMaxIterations === 'number' ? session.scramMaxIterations : DEFAULT_MAX_SCRAM_ITERATIONS
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is session.scramMaxIterations always a number here (at least when used via Client)? If so, I think omitting the check makes sense. (And if supporting direct use really is a goal, startSession seems like the place to do it.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it should always be a number as we do the coerceNumberOrDefault(...).

That would simplify it a bit as the only place it's set is in startSession and this part would be just:

  // a value of 0 disables the iteration count check
  if (this.scramMaxIterations !== 0 && sv.iteration > this.scramMaxIterations) {
    throw new Error(
      'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count ' +
        sv.iteration +
        ' exceeds scramMaxIterations of ' +
        this.scramMaxIterations
    )
  }

Copy link
Copy Markdown
Contributor Author

@sehrope sehrope May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait I realize now that I did it like this because a bunch of the tests create their own session objects and call the other sasl functions:

sasl.continueSession(
    {
      message: 'SASLInitialResponse',
      clientNonce: 'a',
    },
    badPasswordValue,
    'r=1,i=1'
  ),
  {
    message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string',
  }
)

So it wouldn't always be populated unless we update all the tests to add the default.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a shared wrapper function for test use that’s more faithful to how Client will use it?

// a value of 0 disables the iteration count check
if (scramMaxIterations !== 0 && sv.iteration > scramMaxIterations) {
throw new Error(
'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count ' +
sv.iteration +
' exceeds scramMaxIterations of ' +
scramMaxIterations
)
}

const clientFirstMessageBare = 'n=*,r=' + session.clientNonce
const serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration

Expand Down Expand Up @@ -243,4 +258,5 @@ module.exports = {
startSession,
continueSession,
finalizeSession,
DEFAULT_MAX_SCRAM_ITERATIONS,
}
74 changes: 74 additions & 0 deletions packages/pg/test/unit/client/sasl-scram-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ suite.test('sasl/scram', function () {

assert(session1.clientNonce != session2.clientNonce)
})

suite.test('defaults scramMaxIterations to 100000', function () {
const session = sasl.startSession(['SCRAM-SHA-256'])

assert.equal(session.scramMaxIterations, 100000)
})

suite.test('honors a custom scramMaxIterations', function () {
const session = sasl.startSession(['SCRAM-SHA-256'], null, 50)

assert.equal(session.scramMaxIterations, 50)
})
})

suite.test('continueSession', function () {
Expand Down Expand Up @@ -159,6 +171,68 @@ suite.test('sasl/scram', function () {
)
})

suite.test('fails when iteration count exceeds default scramMaxIterations', async function () {
await assert.rejects(
function () {
return sasl.continueSession(
{
message: 'SASLInitialResponse',
clientNonce: 'a',
scramMaxIterations: 100000,
},
'password',
'r=ab,s=abcd,i=100001'
)
},
{
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count 100001 exceeds scramMaxIterations of 100000',
}
)
})

suite.test('fails when iteration count exceeds a custom scramMaxIterations', async function () {
await assert.rejects(
function () {
return sasl.continueSession(
{
message: 'SASLInitialResponse',
clientNonce: 'a',
scramMaxIterations: 10,
},
'password',
'r=ab,s=abcd,i=11'
)
},
{
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count 11 exceeds scramMaxIterations of 10',
}
)
})

suite.test('allows iteration count at the scramMaxIterations limit', async function () {
const session = {
message: 'SASLInitialResponse',
clientNonce: 'a',
scramMaxIterations: 5,
}

await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=5')

assert.equal(session.message, 'SASLResponse')
})

suite.test('disables the iteration count check when scramMaxIterations is 0', async function () {
const session = {
message: 'SASLInitialResponse',
clientNonce: 'a',
scramMaxIterations: 0,
}

await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=999999')

assert.equal(session.message, 'SASLResponse')
})

suite.test('sets expected session data (SCRAM-SHA-256)', async function () {
const session = {
message: 'SASLInitialResponse',
Expand Down
Loading