Skip to content

Commit ad00f82

Browse files
authored
fix: Unauthenticated deletion of installation records via operator injection in device token deduplication ([GHSA-cc6h-c8m4-hgrx](GHSA-cc6h-c8m4-hgrx)) (#10657)
1 parent 84bc500 commit ad00f82

2 files changed

Lines changed: 372 additions & 1 deletion

File tree

spec/vulnerabilities.spec.js

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6921,4 +6921,330 @@ describe('Vulnerabilities', () => {
69216921
await sleep(0);
69226922
});
69236923
});
6924+
6925+
describe('(GHSA-cc6h-c8m4-hgrx) NoSQL injection via _Installation deviceToken deduplication', () => {
6926+
const serverURL = 'http://localhost:8378/1';
6927+
const publicHeaders = {
6928+
'X-Parse-Application-Id': 'test',
6929+
'X-Parse-REST-API-Key': 'rest',
6930+
'Content-Type': 'application/json',
6931+
};
6932+
const attackerInstallationId = 'attacker-uuid-0000-0000-000000000000';
6933+
6934+
const postInstallation = body =>
6935+
request({
6936+
method: 'POST',
6937+
headers: publicHeaders,
6938+
url: `${serverURL}/installations`,
6939+
body: JSON.stringify(body),
6940+
}).catch(e => e);
6941+
6942+
const putInstallation = (objectId, body) =>
6943+
request({
6944+
method: 'PUT',
6945+
headers: publicHeaders,
6946+
url: `${serverURL}/installations/${objectId}`,
6947+
body: JSON.stringify(body),
6948+
}).catch(e => e);
6949+
6950+
const allInstallations = async () => {
6951+
const query = new Parse.Query(Parse.Installation);
6952+
query.limit(1000);
6953+
const results = await query.find({ useMasterKey: true });
6954+
return results.map(r => r.get('installationId')).sort();
6955+
};
6956+
6957+
// Registers `count` unrelated installations, each with its own installationId and
6958+
// deviceToken, exactly as a device SDK would.
6959+
const seedVictimInstallations = async count => {
6960+
for (let i = 0; i < count; i++) {
6961+
const response = await postInstallation({
6962+
installationId: `victim-uuid-0000-0000-00000000000${i}`,
6963+
deviceType: 'ios',
6964+
deviceToken: `victimtoken${i}`,
6965+
});
6966+
expect(response.status).toBe(201);
6967+
}
6968+
};
6969+
6970+
// Doubles as a positive control: proves the unauthenticated client really reaches the
6971+
// application, so a later "nothing was deleted" result cannot be a broken harness.
6972+
const registerAttackerInstallation = async () => {
6973+
const response = await postInstallation({
6974+
installationId: attackerInstallationId,
6975+
deviceType: 'android',
6976+
});
6977+
expect(response.status).toBe(201);
6978+
};
6979+
6980+
it('does not delete other installations when deviceToken is an operator object', async () => {
6981+
await seedVictimInstallations(4);
6982+
await registerAttackerInstallation();
6983+
expect((await allInstallations()).length).toBe(5);
6984+
6985+
const response = await postInstallation({
6986+
installationId: attackerInstallationId,
6987+
deviceToken: { $ne: null },
6988+
});
6989+
6990+
expect(response.status).toBe(400);
6991+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
6992+
expect((await allInstallations()).length).toBe(5);
6993+
});
6994+
6995+
it('does not delete other installations when deviceToken is an operator object and no installation matches the installationId', async () => {
6996+
await seedVictimInstallations(4);
6997+
expect((await allInstallations()).length).toBe(4);
6998+
6999+
// No prior registration, so the request reaches the deduplication branch that runs
7000+
// when no row matches the installationId.
7001+
const response = await postInstallation({
7002+
installationId: 'unregistered-uuid-0000-0000-0000',
7003+
deviceType: 'android',
7004+
deviceToken: { $ne: null },
7005+
});
7006+
7007+
expect(response.status).toBe(400);
7008+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7009+
expect((await allInstallations()).length).toBe(4);
7010+
});
7011+
7012+
it('does not delete targeted installations when deviceToken is a regex operator', async () => {
7013+
await seedVictimInstallations(4);
7014+
await registerAttackerInstallation();
7015+
7016+
const response = await postInstallation({
7017+
installationId: attackerInstallationId,
7018+
deviceToken: { $regex: '^victimtoken' },
7019+
});
7020+
7021+
expect(response.status).toBe(400);
7022+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7023+
expect((await allInstallations()).length).toBe(5);
7024+
});
7025+
7026+
it('does not clear device tokens when deviceToken is an operator object and the duplicate action is update', async () => {
7027+
await reconfigureServer({ installation: { duplicateDeviceTokenAction: 'update' } });
7028+
await seedVictimInstallations(4);
7029+
await registerAttackerInstallation();
7030+
7031+
const response = await postInstallation({
7032+
installationId: attackerInstallationId,
7033+
deviceToken: { $ne: null },
7034+
});
7035+
7036+
expect(response.status).toBe(400);
7037+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7038+
const query = new Parse.Query(Parse.Installation);
7039+
query.exists('deviceToken');
7040+
expect(await query.count({ useMasterKey: true })).toBe(4);
7041+
});
7042+
7043+
it('does not delete other installations when deviceToken is an operator object on update', async () => {
7044+
await seedVictimInstallations(4);
7045+
const created = await postInstallation({
7046+
installationId: attackerInstallationId,
7047+
deviceType: 'android',
7048+
});
7049+
expect(created.status).toBe(201);
7050+
7051+
const response = await putInstallation(created.data.objectId, {
7052+
deviceToken: { $ne: null },
7053+
});
7054+
7055+
expect(response.status).toBe(400);
7056+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7057+
expect((await allInstallations()).length).toBe(5);
7058+
});
7059+
7060+
it('does not delete other installations when appIdentifier is an operator object', async () => {
7061+
await seedVictimInstallations(4);
7062+
await registerAttackerInstallation();
7063+
7064+
const response = await postInstallation({
7065+
installationId: attackerInstallationId,
7066+
deviceToken: 'victimtoken0',
7067+
appIdentifier: { $ne: null },
7068+
});
7069+
7070+
expect(response.status).toBe(400);
7071+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7072+
expect((await allInstallations()).length).toBe(5);
7073+
});
7074+
7075+
it('rejects a non-string installationId with a client error', async () => {
7076+
await seedVictimInstallations(1);
7077+
7078+
const response = await postInstallation({
7079+
installationId: { $ne: null },
7080+
deviceType: 'android',
7081+
deviceToken: 'sometoken',
7082+
});
7083+
7084+
expect(response.status).toBe(400);
7085+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7086+
expect((await allInstallations()).length).toBe(1);
7087+
});
7088+
7089+
it('still allows appIdentifier to be unset with a Delete operation', async () => {
7090+
const created = await postInstallation({
7091+
installationId: 'device-uuid-0000-0000-000000000009',
7092+
deviceType: 'ios',
7093+
deviceToken: 'unsettoken',
7094+
appIdentifier: 'com.example.app',
7095+
});
7096+
expect(created.status).toBe(201);
7097+
7098+
// `appIdentifier` only narrows the deduplication query, so unsetting it is a valid
7099+
// operation that must survive the type validation above.
7100+
const response = await putInstallation(created.data.objectId, {
7101+
appIdentifier: { __op: 'Delete' },
7102+
});
7103+
7104+
expect(response.status).toBe(200);
7105+
const query = new Parse.Query(Parse.Installation);
7106+
query.equalTo('objectId', created.data.objectId);
7107+
const [installation] = await query.find({ useMasterKey: true });
7108+
expect(installation.get('appIdentifier')).toBeUndefined();
7109+
});
7110+
7111+
it('does not clean up installations of other applications when appIdentifier is unset', async () => {
7112+
const victim = await postInstallation({
7113+
installationId: 'victim-uuid-0000-0000-00000000009',
7114+
deviceType: 'ios',
7115+
deviceToken: 'contested-token',
7116+
appIdentifier: 'com.example.victimapp',
7117+
});
7118+
expect(victim.status).toBe(201);
7119+
const attacker = await postInstallation({
7120+
installationId: attackerInstallationId,
7121+
deviceType: 'android',
7122+
deviceToken: 'attacker-token',
7123+
appIdentifier: 'com.example.attackerapp',
7124+
});
7125+
expect(attacker.status).toBe(201);
7126+
7127+
// Claiming the other application's device token while unsetting `appIdentifier` must
7128+
// not drop the constraint that scopes the cleanup to the caller's own application.
7129+
const response = await postInstallation({
7130+
installationId: attackerInstallationId,
7131+
deviceToken: 'contested-token',
7132+
appIdentifier: { __op: 'Delete' },
7133+
});
7134+
expect(response.status).toBe(200);
7135+
7136+
expect(await allInstallations()).toEqual(
7137+
['victim-uuid-0000-0000-00000000009', attackerInstallationId].sort()
7138+
);
7139+
});
7140+
7141+
it('reports the received type when a deviceToken is an array', async () => {
7142+
await seedVictimInstallations(1);
7143+
await registerAttackerInstallation();
7144+
7145+
const response = await postInstallation({
7146+
installationId: attackerInstallationId,
7147+
deviceToken: ['victimtoken0'],
7148+
});
7149+
7150+
expect(response.status).toBe(400);
7151+
expect(response.data.code).toBe(Parse.Error.INCORRECT_TYPE);
7152+
expect(response.data.error).toBe(
7153+
'schema mismatch for _Installation.deviceToken; expected String but got Array'
7154+
);
7155+
expect((await allInstallations()).length).toBe(2);
7156+
});
7157+
7158+
it('skips the cleanup when appIdentifier is unset and the matched installation has none', async () => {
7159+
const victim = await postInstallation({
7160+
installationId: 'victim-uuid-0000-0000-00000000010',
7161+
deviceType: 'ios',
7162+
deviceToken: 'unscoped-token',
7163+
appIdentifier: 'com.example.victimapp',
7164+
});
7165+
expect(victim.status).toBe(201);
7166+
// The caller's own installation carries no application scope to fall back to.
7167+
const attacker = await postInstallation({
7168+
installationId: attackerInstallationId,
7169+
deviceType: 'android',
7170+
deviceToken: 'attacker-token',
7171+
});
7172+
expect(attacker.status).toBe(201);
7173+
7174+
const response = await postInstallation({
7175+
installationId: attackerInstallationId,
7176+
deviceToken: 'unscoped-token',
7177+
appIdentifier: { __op: 'Delete' },
7178+
});
7179+
7180+
expect(response.status).toBe(200);
7181+
expect(await allInstallations()).toEqual(
7182+
['victim-uuid-0000-0000-00000000010', attackerInstallationId].sort()
7183+
);
7184+
});
7185+
7186+
it('skips the cleanup when appIdentifier is unset and no installation matches', async () => {
7187+
const first = await postInstallation({
7188+
installationId: 'victim-uuid-0000-0000-00000000011',
7189+
deviceType: 'ios',
7190+
deviceToken: 'collide-token',
7191+
appIdentifier: 'com.example.appone',
7192+
});
7193+
expect(first.status).toBe(201);
7194+
const second = await postInstallation({
7195+
installationId: 'victim-uuid-0000-0000-00000000012',
7196+
deviceType: 'ios',
7197+
deviceToken: 'collide-token-2',
7198+
appIdentifier: 'com.example.apptwo',
7199+
});
7200+
expect(second.status).toBe(201);
7201+
7202+
// An unregistered installationId reaches the branch that runs when nothing matches.
7203+
const response = await postInstallation({
7204+
installationId: 'unregistered-uuid-0000-0000-0001',
7205+
deviceType: 'android',
7206+
deviceToken: 'collide-token',
7207+
appIdentifier: { __op: 'Delete' },
7208+
});
7209+
7210+
expect(response.status).toBe(201);
7211+
expect(await allInstallations()).toEqual(
7212+
[
7213+
'victim-uuid-0000-0000-00000000011',
7214+
'victim-uuid-0000-0000-00000000012',
7215+
'unregistered-uuid-0000-0000-0001',
7216+
].sort()
7217+
);
7218+
});
7219+
7220+
it('guards every _Installation field that the schema declares as String and the deduplication queries use', () => {
7221+
// The guard in `handleInstallation` hardcodes `String` because the schema's own type
7222+
// check runs too late in the write pipeline to be reused. This pins the two together:
7223+
// if a field is renamed or redeclared, this fails rather than leaving a stale guard.
7224+
const { defaultColumns } = require('../lib/Controllers/SchemaController');
7225+
for (const fieldName of ['deviceToken', 'installationId', 'appIdentifier']) {
7226+
expect(defaultColumns._Installation[fieldName]).toEqual({ type: 'String' });
7227+
}
7228+
});
7229+
7230+
it('still deduplicates installations that share a string deviceToken', async () => {
7231+
const first = await postInstallation({
7232+
installationId: 'device-uuid-0000-0000-000000000001',
7233+
deviceType: 'ios',
7234+
deviceToken: 'sharedtoken',
7235+
});
7236+
expect(first.status).toBe(201);
7237+
7238+
// The same physical device re-registers under a new installationId: the stale row
7239+
// holding the device token must still be cleaned up.
7240+
const second = await postInstallation({
7241+
installationId: 'device-uuid-0000-0000-000000000002',
7242+
deviceType: 'ios',
7243+
deviceToken: 'sharedtoken',
7244+
});
7245+
expect(second.status).toBe(201);
7246+
7247+
expect(await allInstallations()).toEqual(['device-uuid-0000-0000-000000000002']);
7248+
});
7249+
});
69247250
});

src/RestWrite.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,33 @@ RestWrite.prototype.handleInstallation = function () {
13011301
return;
13021302
}
13031303

1304+
// The deduplication below embeds these client-supplied values directly into database
1305+
// queries that delete or update rows with master privileges, and it runs before
1306+
// `validateSchema`, so their types must be enforced here: a non-string value would
1307+
// otherwise reach the database as a query constraint (such as an operator object
1308+
// `{"$ne": null}`) matching rows the client never identified, instead of as a literal
1309+
// value to match against. The schema declares all three as `String`, but that check
1310+
// cannot be reused here; it runs later in the write pipeline and moving it earlier
1311+
// would mutate the schema before the permission check. The field list is a property of
1312+
// this function rather than of the schema: it is the set of values spliced into the
1313+
// deduplication queries below.
1314+
for (const fieldName of ['deviceToken', 'installationId', 'appIdentifier']) {
1315+
const value = this.data[fieldName];
1316+
if (value === undefined || value === null || typeof value === 'string') {
1317+
continue;
1318+
}
1319+
if (fieldName === 'appIdentifier' && value.__op === 'Delete') {
1320+
continue;
1321+
}
1322+
const actualType = Array.isArray(value)
1323+
? 'Array'
1324+
: `${typeof value}`.replace(/^./, character => character.toUpperCase());
1325+
throw new Parse.Error(
1326+
Parse.Error.INCORRECT_TYPE,
1327+
`schema mismatch for _Installation.${fieldName}; expected String but got ${actualType}`
1328+
);
1329+
}
1330+
13041331
if (
13051332
!this.query &&
13061333
!this.data.deviceToken &&
@@ -1463,6 +1490,12 @@ RestWrite.prototype.handleInstallation = function () {
14631490
},
14641491
};
14651492
if (this.data.appIdentifier) {
1493+
// A `Delete` operation is applied only after the deduplication runs, and no
1494+
// installation matched here to take a scope from. Skip the cleanup rather than
1495+
// run it unscoped across every application, or query on the operation itself.
1496+
if (typeof this.data.appIdentifier !== 'string') {
1497+
return;
1498+
}
14661499
delQuery['appIdentifier'] = this.data.appIdentifier;
14671500
}
14681501
const installationOpts = this.config.installation || {};
@@ -1519,7 +1552,19 @@ RestWrite.prototype.handleInstallation = function () {
15191552
return idMatch.objectId;
15201553
}
15211554
if (this.data.appIdentifier) {
1522-
delQuery['appIdentifier'] = this.data.appIdentifier;
1555+
// A `Delete` operation is applied only after the deduplication runs, so scope
1556+
// the cleanup to the value the matched installation still holds. Dropping the
1557+
// constraint would let the cleanup reach installations of other applications,
1558+
// and the operation itself cannot match a String, so skip the cleanup when no
1559+
// scope is available.
1560+
const appIdentifier =
1561+
typeof this.data.appIdentifier === 'string'
1562+
? this.data.appIdentifier
1563+
: idMatch.appIdentifier;
1564+
if (typeof appIdentifier !== 'string') {
1565+
return idMatch.objectId;
1566+
}
1567+
delQuery['appIdentifier'] = appIdentifier;
15231568
}
15241569
const installationOpts = this.config.installation || {};
15251570
return InstallationDedup.removeConflictingDeviceToken({

0 commit comments

Comments
 (0)