fix: 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)

This commit is contained in:
Manuel
2026-02-23 22:01:47 +00:00
committed by GitHub
parent 9935fc7466
commit 9d5942d50e
5 changed files with 148 additions and 128 deletions
+107 -18
View File
@@ -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);
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}.`;
// 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}`
// );
// }
// });
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 () => {
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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,
});
+2 -2
View File
@@ -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,
});
+34 -105
View File
@@ -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,7 +23,6 @@
* 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
@@ -29,85 +30,67 @@
* {
* "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 = {};
// Retrieve Google Signin Keys (with cache control)
function getGoogleKeyByKeyId(keyId) {
if (cache[keyId] && cache.expiresAt > new Date()) {
return cache[keyId];
}
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);
const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
const client = jwksClient({
jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
cache: true,
cacheMaxEntries,
cacheMaxAge,
});
}
async function verifyIdToken({ id_token: token, id }, { clientId }) {
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;
};
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;
}