Skip to content

Commit add233b

Browse files
authored
SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](GHSA-c442-97qw-j6c6)) (parse-community#10179)
1 parent e48c3b5 commit add233b

3 files changed

Lines changed: 268 additions & 6 deletions

File tree

spec/vulnerabilities.spec.js

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,3 +1978,258 @@ describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint
19781978
}
19791979
});
19801980
});
1981+
1982+
describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => {
1983+
const headers = {
1984+
'Content-Type': 'application/json',
1985+
'X-Parse-Application-Id': 'test',
1986+
'X-Parse-REST-API-Key': 'rest',
1987+
'X-Parse-Master-Key': 'test',
1988+
};
1989+
const serverURL = 'http://localhost:8378/1';
1990+
1991+
beforeEach(async () => {
1992+
const obj = new Parse.Object('TestClass');
1993+
obj.set('playerName', 'Alice');
1994+
obj.set('score', 100);
1995+
await obj.save(null, { useMasterKey: true });
1996+
});
1997+
1998+
it('rejects field names containing double quotes in $regex query with master key', async () => {
1999+
const maliciousField = 'playerName" OR 1=1 --';
2000+
const response = await request({
2001+
method: 'GET',
2002+
url: `${serverURL}/classes/TestClass`,
2003+
headers,
2004+
qs: {
2005+
where: JSON.stringify({
2006+
[maliciousField]: { $regex: 'x' },
2007+
}),
2008+
},
2009+
}).catch(e => e);
2010+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2011+
});
2012+
2013+
it('rejects field names containing single quotes in $regex query with master key', async () => {
2014+
const maliciousField = "playerName' OR '1'='1";
2015+
const response = await request({
2016+
method: 'GET',
2017+
url: `${serverURL}/classes/TestClass`,
2018+
headers,
2019+
qs: {
2020+
where: JSON.stringify({
2021+
[maliciousField]: { $regex: 'x' },
2022+
}),
2023+
},
2024+
}).catch(e => e);
2025+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2026+
});
2027+
2028+
it('rejects field names containing semicolons in $regex query with master key', async () => {
2029+
const maliciousField = 'playerName; DROP TABLE "TestClass" --';
2030+
const response = await request({
2031+
method: 'GET',
2032+
url: `${serverURL}/classes/TestClass`,
2033+
headers,
2034+
qs: {
2035+
where: JSON.stringify({
2036+
[maliciousField]: { $regex: 'x' },
2037+
}),
2038+
},
2039+
}).catch(e => e);
2040+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2041+
});
2042+
2043+
it('rejects field names containing parentheses in $regex query with master key', async () => {
2044+
const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --';
2045+
const response = await request({
2046+
method: 'GET',
2047+
url: `${serverURL}/classes/TestClass`,
2048+
headers,
2049+
qs: {
2050+
where: JSON.stringify({
2051+
[maliciousField]: { $regex: 'x' },
2052+
}),
2053+
},
2054+
}).catch(e => e);
2055+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2056+
});
2057+
2058+
it('allows legitimate $regex query with master key', async () => {
2059+
const response = await request({
2060+
method: 'GET',
2061+
url: `${serverURL}/classes/TestClass`,
2062+
headers,
2063+
qs: {
2064+
where: JSON.stringify({
2065+
playerName: { $regex: 'Ali' },
2066+
}),
2067+
},
2068+
});
2069+
expect(response.data.results.length).toBe(1);
2070+
expect(response.data.results[0].playerName).toBe('Alice');
2071+
});
2072+
2073+
it('allows legitimate $regex query with dot notation and master key', async () => {
2074+
const obj = new Parse.Object('TestClass');
2075+
obj.set('metadata', { tag: 'hello-world' });
2076+
await obj.save(null, { useMasterKey: true });
2077+
const response = await request({
2078+
method: 'GET',
2079+
url: `${serverURL}/classes/TestClass`,
2080+
headers,
2081+
qs: {
2082+
where: JSON.stringify({
2083+
'metadata.tag': { $regex: 'hello' },
2084+
}),
2085+
},
2086+
});
2087+
expect(response.data.results.length).toBe(1);
2088+
expect(response.data.results[0].metadata.tag).toBe('hello-world');
2089+
});
2090+
2091+
it('allows legitimate $regex query without master key', async () => {
2092+
const response = await request({
2093+
method: 'GET',
2094+
url: `${serverURL}/classes/TestClass`,
2095+
headers: {
2096+
'Content-Type': 'application/json',
2097+
'X-Parse-Application-Id': 'test',
2098+
'X-Parse-REST-API-Key': 'rest',
2099+
},
2100+
qs: {
2101+
where: JSON.stringify({
2102+
playerName: { $regex: 'Ali' },
2103+
}),
2104+
},
2105+
});
2106+
expect(response.data.results.length).toBe(1);
2107+
expect(response.data.results[0].playerName).toBe('Alice');
2108+
});
2109+
2110+
it('rejects field names with SQL injection via non-$regex operators with master key', async () => {
2111+
const maliciousField = 'playerName" OR 1=1 --';
2112+
const response = await request({
2113+
method: 'GET',
2114+
url: `${serverURL}/classes/TestClass`,
2115+
headers,
2116+
qs: {
2117+
where: JSON.stringify({
2118+
[maliciousField]: { $exists: true },
2119+
}),
2120+
},
2121+
}).catch(e => e);
2122+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2123+
});
2124+
2125+
describe('validateQuery key name enforcement', () => {
2126+
const maliciousField = 'field"; DROP TABLE test --';
2127+
const noMasterHeaders = {
2128+
'Content-Type': 'application/json',
2129+
'X-Parse-Application-Id': 'test',
2130+
'X-Parse-REST-API-Key': 'rest',
2131+
};
2132+
2133+
it('rejects malicious field name in find without master key', async () => {
2134+
const response = await request({
2135+
method: 'GET',
2136+
url: `${serverURL}/classes/TestClass`,
2137+
headers: noMasterHeaders,
2138+
qs: {
2139+
where: JSON.stringify({ [maliciousField]: 'value' }),
2140+
},
2141+
}).catch(e => e);
2142+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2143+
});
2144+
2145+
it('rejects malicious field name in find with master key', async () => {
2146+
const response = await request({
2147+
method: 'GET',
2148+
url: `${serverURL}/classes/TestClass`,
2149+
headers,
2150+
qs: {
2151+
where: JSON.stringify({ [maliciousField]: 'value' }),
2152+
},
2153+
}).catch(e => e);
2154+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2155+
});
2156+
2157+
it('allows master key to query whitelisted internal field _email_verify_token', async () => {
2158+
await reconfigureServer({
2159+
verifyUserEmails: true,
2160+
emailAdapter: {
2161+
sendVerificationEmail: () => Promise.resolve(),
2162+
sendPasswordResetEmail: () => Promise.resolve(),
2163+
sendMail: () => {},
2164+
},
2165+
appName: 'test',
2166+
publicServerURL: 'http://localhost:8378/1',
2167+
});
2168+
const user = new Parse.User();
2169+
user.setUsername('testuser');
2170+
user.setPassword('testpass');
2171+
user.setEmail('test@example.com');
2172+
await user.signUp();
2173+
const response = await request({
2174+
method: 'GET',
2175+
url: `${serverURL}/classes/_User`,
2176+
headers,
2177+
qs: {
2178+
where: JSON.stringify({ _email_verify_token: { $exists: true } }),
2179+
},
2180+
});
2181+
expect(response.data.results.length).toBeGreaterThan(0);
2182+
});
2183+
2184+
it('rejects non-master key querying internal field _email_verify_token', async () => {
2185+
const response = await request({
2186+
method: 'GET',
2187+
url: `${serverURL}/classes/_User`,
2188+
headers: noMasterHeaders,
2189+
qs: {
2190+
where: JSON.stringify({ _email_verify_token: { $exists: true } }),
2191+
},
2192+
}).catch(e => e);
2193+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2194+
});
2195+
2196+
describe('non-master key cannot update internal fields', () => {
2197+
const internalFields = [
2198+
'_rperm',
2199+
'_wperm',
2200+
'_hashed_password',
2201+
'_email_verify_token',
2202+
'_perishable_token',
2203+
'_perishable_token_expires_at',
2204+
'_email_verify_token_expires_at',
2205+
'_failed_login_count',
2206+
'_account_lockout_expires_at',
2207+
'_password_changed_at',
2208+
'_password_history',
2209+
'_tombstone',
2210+
'_session_token',
2211+
];
2212+
2213+
for (const field of internalFields) {
2214+
it(`rejects non-master key updating ${field}`, async () => {
2215+
const user = new Parse.User();
2216+
user.setUsername(`updatetest_${field}`);
2217+
user.setPassword('password123');
2218+
await user.signUp();
2219+
const response = await request({
2220+
method: 'PUT',
2221+
url: `${serverURL}/classes/_User/${user.id}`,
2222+
headers: {
2223+
'Content-Type': 'application/json',
2224+
'X-Parse-Application-Id': 'test',
2225+
'X-Parse-REST-API-Key': 'rest',
2226+
'X-Parse-Session-Token': user.getSessionToken(),
2227+
},
2228+
body: JSON.stringify({ [field]: 'malicious_value' }),
2229+
}).catch(e => e);
2230+
expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
2231+
});
2232+
}
2233+
});
2234+
});
2235+
});

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ const transformDotFieldToComponents = fieldName => {
225225

226226
const transformDotField = fieldName => {
227227
if (fieldName.indexOf('.') === -1) {
228-
return `"${fieldName}"`;
228+
return `"${fieldName.replace(/"/g, '""')}"`;
229229
}
230230
const components = transformDotFieldToComponents(fieldName);
231231
let name = components.slice(0, components.length - 1).join('->');
@@ -760,11 +760,16 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
760760
}
761761
}
762762

763-
const name = transformDotField(fieldName);
764763
regex = processRegexPattern(regex);
765764

766-
patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`);
767-
values.push(name, regex);
765+
if (fieldName.indexOf('.') >= 0) {
766+
const name = transformDotField(fieldName);
767+
patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`);
768+
values.push(name, regex);
769+
} else {
770+
patterns.push(`$${index}:name ${operator} '$${index + 1}:raw'`);
771+
values.push(fieldName, regex);
772+
}
768773
index += 2;
769774
}
770775

src/Controllers/DatabaseController.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,14 @@ const specialMasterQueryKeys = [
6161
...specialQueryKeys,
6262
'_email_verify_token',
6363
'_perishable_token',
64+
'_perishable_token_expires_at',
6465
'_tombstone',
6566
'_email_verify_token_expires_at',
6667
'_failed_login_count',
6768
'_account_lockout_expires_at',
6869
'_password_changed_at',
6970
'_password_history',
71+
'_session_token',
7072
];
7173

7274
const validateQuery = (
@@ -122,8 +124,8 @@ const validateQuery = (
122124
}
123125
if (
124126
!key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) &&
125-
((!specialQueryKeys.includes(key) && !isMaster && !update) ||
126-
(update && isMaster && !specialMasterQueryKeys.includes(key)))
127+
!specialQueryKeys.includes(key) &&
128+
!(isMaster && specialMasterQueryKeys.includes(key))
127129
) {
128130
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`);
129131
}

0 commit comments

Comments
 (0)