Parse Server: Account takeover via JWT algorithm confusion in Google auth adapter
Description
Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 8.6.3 and 9.1.1-alpha.4, an unauthenticated attacker can forge a Google authentication token with alg: "none" to log in as any user linked to a Google account, without knowing their credentials. All deployments with Google authentication enabled are affected. The fix in versions 8.6.3 and 9.1.1-alpha.4 hardcodes the expected RS256 algorithm instead of trusting the JWT header, and replaces the Google adapter's custom key fetcher with jwks-rsa which rejects unknown key IDs. As a workaround, dsable Google authentication until upgrading is possible.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-servernpm | >= 9.0.0, < 9.3.1-alpha.4 | 9.3.1-alpha.4 |
parse-servernpm | < 8.6.3 | 8.6.3 |
Affected products
1- Range: >= 9.0.0, < 9.3.1-alpha.4
Patches
29b94083accb7fix: JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) (#10073)
5 files changed · +148 −128
spec/AuthenticationAdapters.spec.js+108 −19 modified@@ -500,19 +500,60 @@ describe('google auth adapter', () => { } }); - // it('should throw error if public key used to encode token is not available', async () => { - // const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } }; - // try { - // spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); - - // await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); - // fail(); - // } catch (e) { - // expect(e.message).toBe( - // `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` - // ); - // } - // }); + it('should reject forged alg:none JWT from advisory PoC (GHSA-4q3h-vp4r-prv2)', async () => { + const header = Buffer.from('{"alg":"none","kid":"nonexistent-key","typ":"JWT"}').toString('base64url'); + const payload = Buffer.from('{"sub":"the_user_id","iss":"accounts.google.com","aud":"secret","exp":9999999999}').toString('base64url'); + const forgedToken = `${header}.${payload}.`; + + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: forgedToken }, + { clientId: 'secret' } + ); + fail('should have rejected forged token'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'ES256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should throw error if Google signing key is not found', async () => { + const fakeDecodedToken = { kid: '789', alg: 'RS256' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.rejectWith(new Error('key not found')); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Unable to find matching key for Key ID: 789'); + } + }); it('(using client id as string) should verify id_token (google.com)', async () => { const fakeClaim = { @@ -521,8 +562,10 @@ describe('google auth adapter', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); const result = await google.validateAuthData( @@ -537,8 +580,10 @@ describe('google auth adapter', () => { iss: 'https://not.google.com', sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); try { @@ -561,8 +606,10 @@ describe('google auth adapter', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); try { @@ -583,8 +630,10 @@ describe('google auth adapter', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); try { @@ -897,7 +946,27 @@ describe('apple signin auth adapter', () => { { clientId: 'secret' } ); expect(result).toEqual(fakeClaim); - expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); }); it('should not verify invalid id_token', async () => { @@ -1236,7 +1305,27 @@ describe('facebook limited auth adapter', () => { { clientId: 'secret' } ); expect(result).toEqual(fakeClaim); - expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); }); it('should not verify invalid id_token', async () => {
spec/index.spec.js+3 −1 modified@@ -673,8 +673,10 @@ describe('server', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); const user = new Parse.User(); user
src/Adapters/Auth/apple.js+2 −2 modified@@ -77,7 +77,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); } - const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token); + const { kid: keyId } = authUtils.getHeaderFromToken(token); const ONE_HOUR_IN_MS = 3600000; let jwtClaims; @@ -89,7 +89,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa try { jwtClaims = jwt.verify(token, signingKey, { - algorithms: algorithm, + algorithms: ['RS256'], // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. audience: clientId, });
src/Adapters/Auth/facebook.js+2 −2 modified@@ -136,7 +136,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.'); } - const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token); + const { kid: keyId } = authUtils.getHeaderFromToken(token); const ONE_HOUR_IN_MS = 3600000; let jwtClaims; @@ -148,7 +148,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa try { jwtClaims = jwt.verify(token, signingKey, { - algorithms: algorithm, + algorithms: ['RS256'], // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. audience: clientId, });
src/Adapters/Auth/google.js+33 −104 modified@@ -4,6 +4,8 @@ * @class GoogleAdapter * @param {Object} options - The adapter configuration options. * @param {string} options.clientId - Your Google application Client ID. Required for authentication. + * @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5. + * @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour). * * @description * ## Parse Server Configuration @@ -21,93 +23,74 @@ * The adapter requires the following `authData` fields: * - **id**: The Google user ID. * - **id_token**: The Google ID token. - * - **access_token**: The Google access token. * * ## Auth Payload * ### Example Auth Data Payload * ```json * { * "google": { * "id": "1234567", - * "id_token": "xxxxx.yyyyy.zzzzz", - * "access_token": "abc123def456ghi789" + * "id_token": "xxxxx.yyyyy.zzzzz" * } * } * ``` * * ## Notes * - Ensure your Google Client ID is configured properly in the Parse Server configuration. - * - The `id_token` and `access_token` are validated against Google's authentication services. + * - The `id_token` is validated against Google's authentication services. * * @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation} */ 'use strict'; -// Helper functions for accessing the google API. var Parse = require('parse/node').Parse; -const https = require('https'); +const jwksClient = require('jwks-rsa'); const jwt = require('jsonwebtoken'); const authUtils = require('./utils'); const TOKEN_ISSUER = 'accounts.google.com'; const HTTPS_TOKEN_ISSUER = 'https://accounts.google.com'; -let cache = {}; +const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri: 'https://www.googleapis.com/oauth2/v3/certs', + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); -// Retrieve Google Signin Keys (with cache control) -function getGoogleKeyByKeyId(keyId) { - if (cache[keyId] && cache.expiresAt > new Date()) { - return cache[keyId]; + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); } + return key; +}; - return new Promise((resolve, reject) => { - https - .get(`https://www.googleapis.com/oauth2/v3/certs`, res => { - let data = ''; - res.on('data', chunk => { - data += chunk.toString('utf8'); - }); - res.on('end', () => { - const { keys } = JSON.parse(data); - const pems = keys.reduce( - (pems, { n: modulus, e: exposant, kid }) => - Object.assign(pems, { - [kid]: rsaPublicKeyToPEM(modulus, exposant), - }), - {} - ); - - if (res.headers['cache-control']) { - var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/); - - if (expire) { - cache = Object.assign({}, pems, { - expiresAt: new Date(new Date().getTime() + Number(expire[1]) * 1000), - }); - } - } - - resolve(pems[keyId]); - }); - }) - .on('error', reject); - }); -} - -async function verifyIdToken({ id_token: token, id }, { clientId }) { +async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) { if (!token) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); } - const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token); + const { kid: keyId } = authUtils.getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; let jwtClaims; - const googleKey = await getGoogleKeyByKeyId(keyId); + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const googleKey = await getGoogleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = googleKey.publicKey || googleKey.rsaPublicKey; try { - jwtClaims = jwt.verify(token, googleKey, { - algorithms: algorithm, + jwtClaims = jwt.verify(token, signingKey, { + algorithms: ['RS256'], audience: clientId, }); } catch (exception) { @@ -150,57 +133,3 @@ module.exports = { validateAppId: validateAppId, validateAuthData: validateAuthData, }; - -// Helpers functions to convert the RSA certs to PEM (from jwks-rsa) -function rsaPublicKeyToPEM(modulusB64, exponentB64) { - const modulus = new Buffer(modulusB64, 'base64'); - const exponent = new Buffer(exponentB64, 'base64'); - const modulusHex = prepadSigned(modulus.toString('hex')); - const exponentHex = prepadSigned(exponent.toString('hex')); - const modlen = modulusHex.length / 2; - const explen = exponentHex.length / 2; - - const encodedModlen = encodeLengthHex(modlen); - const encodedExplen = encodeLengthHex(explen); - const encodedPubkey = - '30' + - encodeLengthHex(modlen + explen + encodedModlen.length / 2 + encodedExplen.length / 2 + 2) + - '02' + - encodedModlen + - modulusHex + - '02' + - encodedExplen + - exponentHex; - - const der = new Buffer(encodedPubkey, 'hex').toString('base64'); - - let pem = '-----BEGIN RSA PUBLIC KEY-----\n'; - pem += `${der.match(/.{1,64}/g).join('\n')}`; - pem += '\n-----END RSA PUBLIC KEY-----\n'; - return pem; -} - -function prepadSigned(hexStr) { - const msb = hexStr[0]; - if (msb < '0' || msb > '7') { - return `00${hexStr}`; - } - return hexStr; -} - -function toHex(number) { - const nstr = number.toString(16); - if (nstr.length % 2) { - return `0${nstr}`; - } - return nstr; -} - -function encodeLengthHex(n) { - if (n <= 127) { - return toHex(n); - } - const nHex = toHex(n); - const lengthOfLengthByte = 128 + nHex.length / 2; - return toHex(lengthOfLengthByte) + nHex; -}
9d5942d50e55fix: JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) (#10072)
5 files changed · +148 −128
spec/AuthenticationAdapters.spec.js+108 −19 modified@@ -500,19 +500,60 @@ describe('google auth adapter', () => { } }); - // it('should throw error if public key used to encode token is not available', async () => { - // const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } }; - // try { - // spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); - - // await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); - // fail(); - // } catch (e) { - // expect(e.message).toBe( - // `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` - // ); - // } - // }); + it('should reject forged alg:none JWT from advisory PoC (GHSA-4q3h-vp4r-prv2)', async () => { + const header = Buffer.from('{"alg":"none","kid":"nonexistent-key","typ":"JWT"}').toString('base64url'); + const payload = Buffer.from('{"sub":"the_user_id","iss":"accounts.google.com","aud":"secret","exp":9999999999}').toString('base64url'); + const forgedToken = `${header}.${payload}.`; + + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: forgedToken }, + { clientId: 'secret' } + ); + fail('should have rejected forged token'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'ES256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should throw error if Google signing key is not found', async () => { + const fakeDecodedToken = { kid: '789', alg: 'RS256' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.rejectWith(new Error('key not found')); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Unable to find matching key for Key ID: 789'); + } + }); it('(using client id as string) should verify id_token (google.com)', async () => { const fakeClaim = { @@ -521,8 +562,10 @@ describe('google auth adapter', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); const result = await google.validateAuthData( @@ -537,8 +580,10 @@ describe('google auth adapter', () => { iss: 'https://not.google.com', sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); try { @@ -561,8 +606,10 @@ describe('google auth adapter', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); try { @@ -583,8 +630,10 @@ describe('google auth adapter', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); try { @@ -897,7 +946,27 @@ describe('apple signin auth adapter', () => { { clientId: 'secret' } ); expect(result).toEqual(fakeClaim); - expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); }); it('should not verify invalid id_token', async () => { @@ -1236,7 +1305,27 @@ describe('facebook limited auth adapter', () => { { clientId: 'secret' } ); expect(result).toEqual(fakeClaim); - expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); }); it('should not verify invalid id_token', async () => {
spec/index.spec.js+3 −1 modified@@ -673,8 +673,10 @@ describe('server', () => { exp: Date.now(), sub: 'the_user_id', }; - const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); spyOn(jwt, 'verify').and.callFake(() => fakeClaim); const user = new Parse.User(); user
src/Adapters/Auth/apple.js+2 −2 modified@@ -77,7 +77,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); } - const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token); + const { kid: keyId } = authUtils.getHeaderFromToken(token); const ONE_HOUR_IN_MS = 3600000; let jwtClaims; @@ -89,7 +89,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa try { jwtClaims = jwt.verify(token, signingKey, { - algorithms: algorithm, + algorithms: ['RS256'], // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. audience: clientId, });
src/Adapters/Auth/facebook.js+2 −2 modified@@ -136,7 +136,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.'); } - const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token); + const { kid: keyId } = authUtils.getHeaderFromToken(token); const ONE_HOUR_IN_MS = 3600000; let jwtClaims; @@ -148,7 +148,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa try { jwtClaims = jwt.verify(token, signingKey, { - algorithms: algorithm, + algorithms: ['RS256'], // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. audience: clientId, });
src/Adapters/Auth/google.js+33 −104 modified@@ -4,6 +4,8 @@ * @class GoogleAdapter * @param {Object} options - The adapter configuration options. * @param {string} options.clientId - Your Google application Client ID. Required for authentication. + * @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5. + * @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour). * * @description * ## Parse Server Configuration @@ -21,93 +23,74 @@ * The adapter requires the following `authData` fields: * - **id**: The Google user ID. * - **id_token**: The Google ID token. - * - **access_token**: The Google access token. * * ## Auth Payload * ### Example Auth Data Payload * ```json * { * "google": { * "id": "1234567", - * "id_token": "xxxxx.yyyyy.zzzzz", - * "access_token": "abc123def456ghi789" + * "id_token": "xxxxx.yyyyy.zzzzz" * } * } * ``` * * ## Notes * - Ensure your Google Client ID is configured properly in the Parse Server configuration. - * - The `id_token` and `access_token` are validated against Google's authentication services. + * - The `id_token` is validated against Google's authentication services. * * @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation} */ 'use strict'; -// Helper functions for accessing the google API. var Parse = require('parse/node').Parse; -const https = require('https'); +const jwksClient = require('jwks-rsa'); const jwt = require('jsonwebtoken'); const authUtils = require('./utils'); const TOKEN_ISSUER = 'accounts.google.com'; const HTTPS_TOKEN_ISSUER = 'https://accounts.google.com'; -let cache = {}; +const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri: 'https://www.googleapis.com/oauth2/v3/certs', + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); -// Retrieve Google Signin Keys (with cache control) -function getGoogleKeyByKeyId(keyId) { - if (cache[keyId] && cache.expiresAt > new Date()) { - return cache[keyId]; + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); } + return key; +}; - return new Promise((resolve, reject) => { - https - .get(`https://www.googleapis.com/oauth2/v3/certs`, res => { - let data = ''; - res.on('data', chunk => { - data += chunk.toString('utf8'); - }); - res.on('end', () => { - const { keys } = JSON.parse(data); - const pems = keys.reduce( - (pems, { n: modulus, e: exposant, kid }) => - Object.assign(pems, { - [kid]: rsaPublicKeyToPEM(modulus, exposant), - }), - {} - ); - - if (res.headers['cache-control']) { - var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/); - - if (expire) { - cache = Object.assign({}, pems, { - expiresAt: new Date(new Date().getTime() + Number(expire[1]) * 1000), - }); - } - } - - resolve(pems[keyId]); - }); - }) - .on('error', reject); - }); -} - -async function verifyIdToken({ id_token: token, id }, { clientId }) { +async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) { if (!token) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); } - const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token); + const { kid: keyId } = authUtils.getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; let jwtClaims; - const googleKey = await getGoogleKeyByKeyId(keyId); + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const googleKey = await getGoogleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = googleKey.publicKey || googleKey.rsaPublicKey; try { - jwtClaims = jwt.verify(token, googleKey, { - algorithms: algorithm, + jwtClaims = jwt.verify(token, signingKey, { + algorithms: ['RS256'], audience: clientId, }); } catch (exception) { @@ -150,57 +133,3 @@ module.exports = { validateAppId: validateAppId, validateAuthData: validateAuthData, }; - -// Helpers functions to convert the RSA certs to PEM (from jwks-rsa) -function rsaPublicKeyToPEM(modulusB64, exponentB64) { - const modulus = new Buffer(modulusB64, 'base64'); - const exponent = new Buffer(exponentB64, 'base64'); - const modulusHex = prepadSigned(modulus.toString('hex')); - const exponentHex = prepadSigned(exponent.toString('hex')); - const modlen = modulusHex.length / 2; - const explen = exponentHex.length / 2; - - const encodedModlen = encodeLengthHex(modlen); - const encodedExplen = encodeLengthHex(explen); - const encodedPubkey = - '30' + - encodeLengthHex(modlen + explen + encodedModlen.length / 2 + encodedExplen.length / 2 + 2) + - '02' + - encodedModlen + - modulusHex + - '02' + - encodedExplen + - exponentHex; - - const der = new Buffer(encodedPubkey, 'hex').toString('base64'); - - let pem = '-----BEGIN RSA PUBLIC KEY-----\n'; - pem += `${der.match(/.{1,64}/g).join('\n')}`; - pem += '\n-----END RSA PUBLIC KEY-----\n'; - return pem; -} - -function prepadSigned(hexStr) { - const msb = hexStr[0]; - if (msb < '0' || msb > '7') { - return `00${hexStr}`; - } - return hexStr; -} - -function toHex(number) { - const nstr = number.toString(16); - if (nstr.length % 2) { - return `0${nstr}`; - } - return nstr; -} - -function encodeLengthHex(n) { - if (n <= 127) { - return toHex(n); - } - const nHex = toHex(n); - const lengthOfLengthByte = 128 + nHex.length / 2; - return toHex(lengthOfLengthByte) + nHex; -}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-4q3h-vp4r-prv2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27804ghsaADVISORY
- github.com/parse-community/parse-server/commit/9b94083accb7f3e72c6b8126c195c7a03dd2dfd7ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/releases/tag/8.6.3ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/releases/tag/9.3.1-alpha.4ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.