Skip to content
Merged
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
255 changes: 255 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2112,3 +2112,258 @@ describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint
}
});
});

describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Master-Key': 'test',
};
const serverURL = 'http://localhost:8378/1';

beforeEach(async () => {
const obj = new Parse.Object('TestClass');
obj.set('playerName', 'Alice');
obj.set('score', 100);
await obj.save(null, { useMasterKey: true });
});

it('rejects field names containing double quotes in $regex query with master key', async () => {
const maliciousField = 'playerName" OR 1=1 --';
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
[maliciousField]: { $regex: 'x' },
}),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

it('rejects field names containing single quotes in $regex query with master key', async () => {
const maliciousField = "playerName' OR '1'='1";
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
[maliciousField]: { $regex: 'x' },
}),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

it('rejects field names containing semicolons in $regex query with master key', async () => {
const maliciousField = 'playerName; DROP TABLE "TestClass" --';
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
[maliciousField]: { $regex: 'x' },
}),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

it('rejects field names containing parentheses in $regex query with master key', async () => {
const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --';
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
[maliciousField]: { $regex: 'x' },
}),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

it('allows legitimate $regex query with master key', async () => {
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
playerName: { $regex: 'Ali' },
}),
},
});
expect(response.data.results.length).toBe(1);
expect(response.data.results[0].playerName).toBe('Alice');
});

it('allows legitimate $regex query with dot notation and master key', async () => {
const obj = new Parse.Object('TestClass');
obj.set('metadata', { tag: 'hello-world' });
await obj.save(null, { useMasterKey: true });
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
'metadata.tag': { $regex: 'hello' },
}),
},
});
expect(response.data.results.length).toBe(1);
expect(response.data.results[0].metadata.tag).toBe('hello-world');
});

it('allows legitimate $regex query without master key', async () => {
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
qs: {
where: JSON.stringify({
playerName: { $regex: 'Ali' },
}),
},
});
expect(response.data.results.length).toBe(1);
expect(response.data.results[0].playerName).toBe('Alice');
});

it('rejects field names with SQL injection via non-$regex operators with master key', async () => {
const maliciousField = 'playerName" OR 1=1 --';
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({
[maliciousField]: { $exists: true },
}),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

describe('validateQuery key name enforcement', () => {
const maliciousField = 'field"; DROP TABLE test --';
const noMasterHeaders = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};

it('rejects malicious field name in find without master key', async () => {
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers: noMasterHeaders,
qs: {
where: JSON.stringify({ [maliciousField]: 'value' }),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

it('rejects malicious field name in find with master key', async () => {
const response = await request({
method: 'GET',
url: `${serverURL}/classes/TestClass`,
headers,
qs: {
where: JSON.stringify({ [maliciousField]: 'value' }),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

it('allows master key to query whitelisted internal field _email_verify_token', async () => {
await reconfigureServer({
verifyUserEmails: true,
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
},
appName: 'test',
publicServerURL: 'http://localhost:8378/1',
});
const user = new Parse.User();
user.setUsername('testuser');
user.setPassword('testpass');
user.setEmail('test@example.com');
await user.signUp();
const response = await request({
method: 'GET',
url: `${serverURL}/classes/_User`,
headers,
qs: {
where: JSON.stringify({ _email_verify_token: { $exists: true } }),
},
});
expect(response.data.results.length).toBeGreaterThan(0);
});

it('rejects non-master key querying internal field _email_verify_token', async () => {
const response = await request({
method: 'GET',
url: `${serverURL}/classes/_User`,
headers: noMasterHeaders,
qs: {
where: JSON.stringify({ _email_verify_token: { $exists: true } }),
},
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});

describe('non-master key cannot update internal fields', () => {
const internalFields = [
'_rperm',
'_wperm',
'_hashed_password',
'_email_verify_token',
'_perishable_token',
'_perishable_token_expires_at',
'_email_verify_token_expires_at',
'_failed_login_count',
'_account_lockout_expires_at',
'_password_changed_at',
'_password_history',
'_tombstone',
'_session_token',
];

for (const field of internalFields) {
it(`rejects non-master key updating ${field}`, async () => {
const user = new Parse.User();
user.setUsername(`updatetest_${field}`);
user.setPassword('password123');
await user.signUp();
const response = await request({
method: 'PUT',
url: `${serverURL}/classes/_User/${user.id}`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
},
body: JSON.stringify({ [field]: 'malicious_value' }),
}).catch(e => e);
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});
}
});
});
});
13 changes: 9 additions & 4 deletions src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ const transformDotFieldToComponents = fieldName => {

const transformDotField = fieldName => {
if (fieldName.indexOf('.') === -1) {
return `"${fieldName}"`;
return `"${fieldName.replace(/"/g, '""')}"`;
}
const components = transformDotFieldToComponents(fieldName);
let name = components.slice(0, components.length - 1).join('->');
Expand Down Expand Up @@ -760,11 +760,16 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
}
}

const name = transformDotField(fieldName);
regex = processRegexPattern(regex);

patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`);
values.push(name, regex);
if (fieldName.indexOf('.') >= 0) {
const name = transformDotField(fieldName);
patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`);
values.push(name, regex);
} else {
patterns.push(`$${index}:name ${operator} '$${index + 1}:raw'`);
values.push(fieldName, regex);
}
index += 2;
}

Expand Down
84 changes: 58 additions & 26 deletions src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,61 @@ import type { ParseServerOptions } from '../Options';
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
import { createSanitizedError } from '../Error';

// Query operators that always pass validation regardless of auth level.
const queryOperators = ['$and', '$or', '$nor'];

// Registry of internal fields with access permissions.
// Internal fields are never directly writable by clients, so clientWrite is omitted.
// - clientRead: any client can use this field in queries
// - masterRead: master key can use this field in queries
// - masterWrite: master key can use this field in updates
const internalFields = {
_rperm: { clientRead: true, masterRead: true, masterWrite: true },
_wperm: { clientRead: true, masterRead: true, masterWrite: true },
_hashed_password: { clientRead: false, masterRead: false, masterWrite: true },
_email_verify_token: { clientRead: false, masterRead: true, masterWrite: true },
_perishable_token: { clientRead: false, masterRead: true, masterWrite: true },
_perishable_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true },
_email_verify_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true },
_failed_login_count: { clientRead: false, masterRead: true, masterWrite: true },
_account_lockout_expires_at: { clientRead: false, masterRead: true, masterWrite: true },
_password_changed_at: { clientRead: false, masterRead: true, masterWrite: true },
_password_history: { clientRead: false, masterRead: true, masterWrite: true },
_tombstone: { clientRead: false, masterRead: true, masterWrite: false },
_session_token: { clientRead: false, masterRead: true, masterWrite: false },
/////////////////////////////////////////////////////////////////////////////////////////////
// The following fields are not accessed by their _-prefixed name through the API;
// they are mapped to REST-level names in the adapter layer or handled through
// separate code paths.
/////////////////////////////////////////////////////////////////////////////////////////////
// System fields (mapped to REST-level names):
// _id (objectId)
// _created_at (createdAt)
// _updated_at (updatedAt)
// _last_used (lastUsed)
// _expiresAt (expiresAt)
/////////////////////////////////////////////////////////////////////////////////////////////
// Legacy ACL format: mapped to/from _rperm/_wperm
// _acl
/////////////////////////////////////////////////////////////////////////////////////////////
// Schema metadata: not data fields, used only for schema configuration
// _metadata
// _client_permissions
/////////////////////////////////////////////////////////////////////////////////////////////
// Dynamic auth data fields: used only in projections and updates, not in queries
// _auth_data_<provider>
};

// Derived access lists
const specialQueryKeys = [
...queryOperators,
...Object.keys(internalFields).filter(k => internalFields[k].clientRead),
];
const specialMasterQueryKeys = [
...queryOperators,
...Object.keys(internalFields).filter(k => internalFields[k].masterRead),
];

function addWriteACL(query, acl) {
const newQuery = _.cloneDeep(query);
//Can't be any existing '_wperm' query, we don't allow client queries on that, no need to $and
Expand Down Expand Up @@ -56,19 +111,6 @@ const transformObjectACL = ({ ACL, ...result }) => {
return result;
};

const specialQueryKeys = ['$and', '$or', '$nor', '_rperm', '_wperm'];
const specialMasterQueryKeys = [
...specialQueryKeys,
'_email_verify_token',
'_perishable_token',
'_tombstone',
'_email_verify_token_expires_at',
'_failed_login_count',
'_account_lockout_expires_at',
'_password_changed_at',
'_password_history',
];

const validateQuery = (
query: any,
isMaster: boolean,
Expand Down Expand Up @@ -122,8 +164,8 @@ const validateQuery = (
}
if (
!key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) &&
((!specialQueryKeys.includes(key) && !isMaster && !update) ||
(update && isMaster && !specialMasterQueryKeys.includes(key)))
!specialQueryKeys.includes(key) &&
!(isMaster && specialMasterQueryKeys.includes(key))
) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`);
}
Expand Down Expand Up @@ -250,17 +292,7 @@ const filterSensitiveData = (
// acl: a list of strings. If the object to be updated has an ACL,
// one of the provided strings must provide the caller with
// write permissions.
const specialKeysForUpdate = [
'_hashed_password',
'_perishable_token',
'_email_verify_token',
'_email_verify_token_expires_at',
'_account_lockout_expires_at',
'_failed_login_count',
'_perishable_token_expires_at',
'_password_changed_at',
'_password_history',
];
const specialKeysForUpdate = Object.keys(internalFields).filter(k => internalFields[k].masterWrite);

const isSpecialUpdateKey = key => {
return specialKeysForUpdate.indexOf(key) >= 0;
Expand Down
Loading