Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
216 changes: 216 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2112,3 +2112,219 @@ 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);
});
});
});
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
4 changes: 2 additions & 2 deletions src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,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
Loading