Skip to content

Commit a109e8c

Browse files
authored
Add more SASL validation and fix tests (#2436)
* Add sha256 SASL helper * Rename internal createHMAC(...) to hmacSha256(...) * Add parseAttributePairs(...) helper for SASL * Tighten arg checks in SASL xorBuffers(...) * Add SASL nonce check for printable chars * Add SASL server salt and server signature base64 validation * Add check for non-empty SASL server nonce * Rename SASL helper to parseServerFirstMessage(...) * Add parameter validation to SASL continueSession(...) * Split out SASL final message parsing into parseServerFinalMessage(...) * Fix SCRAM tests Removes custom assert.throws(...) so that the real one from the assert package is used and fixes the SCRAM tests to reflect the updated error messages and actual checking of errors. Previously the custom assert.throws(...) was ignoring the error signature validation.
1 parent afb3bf3 commit a109e8c

File tree

3 files changed

+141
-72
lines changed

3 files changed

+141
-72
lines changed

Diff for: packages/pg/lib/sasl.js

+110-52
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,27 @@ function continueSession(session, password, serverData) {
2020
if (session.message !== 'SASLInitialResponse') {
2121
throw new Error('SASL: Last message was not SASLInitialResponse')
2222
}
23+
if (typeof password !== 'string') {
24+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string')
25+
}
26+
if (typeof serverData !== 'string') {
27+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: serverData must be a string')
28+
}
2329

24-
const sv = extractVariablesFromFirstServerMessage(serverData)
30+
const sv = parseServerFirstMessage(serverData)
2531

2632
if (!sv.nonce.startsWith(session.clientNonce)) {
2733
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce')
34+
} else if (sv.nonce.length === session.clientNonce.length) {
35+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short')
2836
}
2937

3038
var saltBytes = Buffer.from(sv.salt, 'base64')
3139

3240
var saltedPassword = Hi(password, saltBytes, sv.iteration)
3341

34-
var clientKey = createHMAC(saltedPassword, 'Client Key')
35-
var storedKey = crypto.createHash('sha256').update(clientKey).digest()
42+
var clientKey = hmacSha256(saltedPassword, 'Client Key')
43+
var storedKey = sha256(clientKey)
3644

3745
var clientFirstMessageBare = 'n=*,r=' + session.clientNonce
3846
var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
@@ -41,12 +49,12 @@ function continueSession(session, password, serverData) {
4149

4250
var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof
4351

44-
var clientSignature = createHMAC(storedKey, authMessage)
52+
var clientSignature = hmacSha256(storedKey, authMessage)
4553
var clientProofBytes = xorBuffers(clientKey, clientSignature)
4654
var clientProof = clientProofBytes.toString('base64')
4755

48-
var serverKey = createHMAC(saltedPassword, 'Server Key')
49-
var serverSignatureBytes = createHMAC(serverKey, authMessage)
56+
var serverKey = hmacSha256(saltedPassword, 'Server Key')
57+
var serverSignatureBytes = hmacSha256(serverKey, authMessage)
5058

5159
session.message = 'SASLResponse'
5260
session.serverSignature = serverSignatureBytes.toString('base64')
@@ -57,54 +65,87 @@ function finalizeSession(session, serverData) {
5765
if (session.message !== 'SASLResponse') {
5866
throw new Error('SASL: Last message was not SASLResponse')
5967
}
68+
if (typeof serverData !== 'string') {
69+
throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: serverData must be a string')
70+
}
6071

61-
var serverSignature
62-
63-
String(serverData)
64-
.split(',')
65-
.forEach(function (part) {
66-
switch (part[0]) {
67-
case 'v':
68-
serverSignature = part.substr(2)
69-
break
70-
}
71-
})
72+
const { serverSignature } = parseServerFinalMessage(serverData)
7273

7374
if (serverSignature !== session.serverSignature) {
7475
throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match')
7576
}
7677
}
7778

78-
function extractVariablesFromFirstServerMessage(data) {
79-
var nonce, salt, iteration
80-
81-
String(data)
82-
.split(',')
83-
.forEach(function (part) {
84-
switch (part[0]) {
85-
case 'r':
86-
nonce = part.substr(2)
87-
break
88-
case 's':
89-
salt = part.substr(2)
90-
break
91-
case 'i':
92-
iteration = parseInt(part.substr(2), 10)
93-
break
79+
/**
80+
* printable = %x21-2B / %x2D-7E
81+
* ;; Printable ASCII except ",".
82+
* ;; Note that any "printable" is also
83+
* ;; a valid "value".
84+
*/
85+
function isPrintableChars(text) {
86+
if (typeof text !== 'string') {
87+
throw new TypeError('SASL: text must be a string')
88+
}
89+
return text
90+
.split('')
91+
.map((_, i) => text.charCodeAt(i))
92+
.every((c) => (c >= 0x21 && c <= 0x2b) || (c >= 0x2d && c <= 0x7e))
93+
}
94+
95+
/**
96+
* base64-char = ALPHA / DIGIT / "/" / "+"
97+
*
98+
* base64-4 = 4base64-char
99+
*
100+
* base64-3 = 3base64-char "="
101+
*
102+
* base64-2 = 2base64-char "=="
103+
*
104+
* base64 = *base64-4 [base64-3 / base64-2]
105+
*/
106+
function isBase64(text) {
107+
return /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/.test(text)
108+
}
109+
110+
function parseAttributePairs(text) {
111+
if (typeof text !== 'string') {
112+
throw new TypeError('SASL: attribute pairs text must be a string')
113+
}
114+
115+
return new Map(
116+
text.split(',').map((attrValue) => {
117+
if (!/^.=/.test(attrValue)) {
118+
throw new Error('SASL: Invalid attribute pair entry')
94119
}
120+
const name = attrValue[0]
121+
const value = attrValue.substring(2)
122+
return [name, value]
95123
})
124+
)
125+
}
96126

127+
function parseServerFirstMessage(data) {
128+
const attrPairs = parseAttributePairs(data)
129+
130+
const nonce = attrPairs.get('r')
97131
if (!nonce) {
98132
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing')
133+
} else if (!isPrintableChars(nonce)) {
134+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce must only contain printable characters')
99135
}
100-
136+
const salt = attrPairs.get('s')
101137
if (!salt) {
102138
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing')
139+
} else if (!isBase64(salt)) {
140+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64')
103141
}
104-
105-
if (!iteration) {
142+
const iterationText = attrPairs.get('i')
143+
if (!iterationText) {
106144
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing')
145+
} else if (!/^[1-9][0-9]*$/.test(iterationText)) {
146+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: invalid iteration count')
107147
}
148+
const iteration = parseInt(iterationText, 10)
108149

109150
return {
110151
nonce,
@@ -113,31 +154,48 @@ function extractVariablesFromFirstServerMessage(data) {
113154
}
114155
}
115156

157+
function parseServerFinalMessage(serverData) {
158+
const attrPairs = parseAttributePairs(serverData)
159+
const serverSignature = attrPairs.get('v')
160+
if (!serverSignature) {
161+
throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing')
162+
} else if (!isBase64(serverSignature)) {
163+
throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64')
164+
}
165+
return {
166+
serverSignature,
167+
}
168+
}
169+
116170
function xorBuffers(a, b) {
117-
if (!Buffer.isBuffer(a)) a = Buffer.from(a)
118-
if (!Buffer.isBuffer(b)) b = Buffer.from(b)
119-
var res = []
120-
if (a.length > b.length) {
121-
for (var i = 0; i < b.length; i++) {
122-
res.push(a[i] ^ b[i])
123-
}
124-
} else {
125-
for (var j = 0; j < a.length; j++) {
126-
res.push(a[j] ^ b[j])
127-
}
128-
}
129-
return Buffer.from(res)
171+
if (!Buffer.isBuffer(a)) {
172+
throw new TypeError('first argument must be a Buffer')
173+
}
174+
if (!Buffer.isBuffer(b)) {
175+
throw new TypeError('second argument must be a Buffer')
176+
}
177+
if (a.length !== b.length) {
178+
throw new Error('Buffer lengths must match')
179+
}
180+
if (a.length === 0) {
181+
throw new Error('Buffers cannot be empty')
182+
}
183+
return Buffer.from(a.map((_, i) => a[i] ^ b[i]))
184+
}
185+
186+
function sha256(text) {
187+
return crypto.createHash('sha256').update(text).digest()
130188
}
131189

132-
function createHMAC(key, msg) {
190+
function hmacSha256(key, msg) {
133191
return crypto.createHmac('sha256', key).update(msg).digest()
134192
}
135193

136194
function Hi(password, saltBytes, iterations) {
137-
var ui1 = createHMAC(password, Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])]))
195+
var ui1 = hmacSha256(password, Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])]))
138196
var ui = ui1
139197
for (var i = 0; i < iterations - 1; i++) {
140-
ui1 = createHMAC(password, ui1)
198+
ui1 = hmacSha256(password, ui1)
141199
ui = xorBuffers(ui, ui1)
142200
}
143201

Diff for: packages/pg/test/test-helper.js

-10
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,6 @@ assert.success = function (callback) {
111111
}
112112
}
113113

114-
assert.throws = function (offender) {
115-
try {
116-
offender()
117-
} catch (e) {
118-
assert.ok(e instanceof Error, 'Expected ' + offender + ' to throw instances of Error')
119-
return
120-
}
121-
assert.ok(false, 'Expected ' + offender + ' to throw exception')
122-
}
123-
124114
assert.lengthIs = function (actual, expectedLength) {
125115
assert.equal(actual.length, expectedLength)
126116
}

Diff for: packages/pg/test/unit/client/sasl-scram-tests.js

+31-10
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ test('sasl/scram', function () {
3838
test('fails when last session message was not SASLInitialResponse', function () {
3939
assert.throws(
4040
function () {
41-
sasl.continueSession({})
41+
sasl.continueSession({}, '', '')
4242
},
4343
{
4444
message: 'SASL: Last message was not SASLInitialResponse',
@@ -53,6 +53,7 @@ test('sasl/scram', function () {
5353
{
5454
message: 'SASLInitialResponse',
5555
},
56+
'bad-password',
5657
's=1,i=1'
5758
)
5859
},
@@ -69,6 +70,7 @@ test('sasl/scram', function () {
6970
{
7071
message: 'SASLInitialResponse',
7172
},
73+
'bad-password',
7274
'r=1,i=1'
7375
)
7476
},
@@ -85,7 +87,8 @@ test('sasl/scram', function () {
8587
{
8688
message: 'SASLInitialResponse',
8789
},
88-
'r=1,s=1'
90+
'bad-password',
91+
'r=1,s=abcd'
8992
)
9093
},
9194
{
@@ -102,7 +105,8 @@ test('sasl/scram', function () {
102105
message: 'SASLInitialResponse',
103106
clientNonce: '2',
104107
},
105-
'r=1,s=1,i=1'
108+
'bad-password',
109+
'r=1,s=abcd,i=1'
106110
)
107111
},
108112
{
@@ -117,12 +121,12 @@ test('sasl/scram', function () {
117121
clientNonce: 'a',
118122
}
119123

120-
sasl.continueSession(session, 'password', 'r=ab,s=x,i=1')
124+
sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1')
121125

122126
assert.equal(session.message, 'SASLResponse')
123-
assert.equal(session.serverSignature, 'TtywIrpWDJ0tCSXM2mjkyiaa8iGZsZG7HllQxr8fYAo=')
127+
assert.equal(session.serverSignature, 'jwt97IHWFn7FEqHykPTxsoQrKGOMXJl/PJyJ1JXTBKc=')
124128

125-
assert.equal(session.response, 'c=biws,r=ab,p=KAEPBUTjjofB0IM5UWcZApK1dSzFE0o5vnbWjBbvFHA=')
129+
assert.equal(session.response, 'c=biws,r=ab,p=mU8grLfTjDrJer9ITsdHk0igMRDejG10EJPFbIBL3D0=')
126130
})
127131
})
128132

@@ -138,15 +142,32 @@ test('sasl/scram', function () {
138142
)
139143
})
140144

145+
test('fails when server signature is not valid base64', function () {
146+
assert.throws(
147+
function () {
148+
sasl.finalizeSession(
149+
{
150+
message: 'SASLResponse',
151+
serverSignature: 'abcd',
152+
},
153+
'v=x1' // Purposefully invalid base64
154+
)
155+
},
156+
{
157+
message: 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64',
158+
}
159+
)
160+
})
161+
141162
test('fails when server signature does not match', function () {
142163
assert.throws(
143164
function () {
144165
sasl.finalizeSession(
145166
{
146167
message: 'SASLResponse',
147-
serverSignature: '3',
168+
serverSignature: 'abcd',
148169
},
149-
'v=4'
170+
'v=xyzq'
150171
)
151172
},
152173
{
@@ -159,9 +180,9 @@ test('sasl/scram', function () {
159180
sasl.finalizeSession(
160181
{
161182
message: 'SASLResponse',
162-
serverSignature: '5',
183+
serverSignature: 'abcd',
163184
},
164-
'v=5'
185+
'v=abcd'
165186
)
166187
})
167188
})

0 commit comments

Comments
 (0)