mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c692c3e6e6 |
@@ -1,17 +1,3 @@
|
||||
## [9.9.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.4...9.9.1-alpha.5) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Endpoints `/login` and `/verifyPassword` disclose MFA secrets and protected fields when `_User` get is denied ([GHSA-75v4-m273-5j49](https://github.com/parse-community/parse-server/security/advisories/GHSA-75v4-m273-5j49)) ([#10492](https://github.com/parse-community/parse-server/issues/10492)) ([83e90ed](https://github.com/parse-community/parse-server/commit/83e90edbe4224c81172a20e40fa986662c9394ca))
|
||||
|
||||
## [9.9.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.3...9.9.1-alpha.4) (2026-06-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](https://github.com/parse-community/parse-server/security/advisories/GHSA-7wqv-xjf3-x35v)) ([#10489](https://github.com/parse-community/parse-server/issues/10489)) ([66484ce](https://github.com/parse-community/parse-server/commit/66484ce8fdd87a5d4c23bf9e40f7ea379b4dce79))
|
||||
|
||||
## [9.9.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.2...9.9.1-alpha.3) (2026-05-27)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.5",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.5",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.5",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
@@ -447,25 +447,4 @@ describe('Utils', () => {
|
||||
expect(Utils.isObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
const cases = [
|
||||
['file.txt', 'txt'],
|
||||
['file.tar.gz', 'gz'],
|
||||
['.hidden', 'hidden'],
|
||||
['file.', ''],
|
||||
['file..', ''],
|
||||
['file', ''],
|
||||
['', ''],
|
||||
[null, ''],
|
||||
[undefined, ''],
|
||||
['poc.svg.', ''],
|
||||
['archive.tar.gz.', ''],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => {
|
||||
expect(Utils.getFileExtension(input)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1749,174 +1749,6 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-7wqv-xjf3-x35v) Stored XSS via trailing-dot filename bypassing file extension blocklist', () => {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks trailing-dot SVG filename with dangerous _ContentType on JSON-body upload', async () => {
|
||||
const svgContent = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
).toString('base64');
|
||||
// No X-Parse-Application-Id header — must be in JSON body to trigger
|
||||
// _ContentType extraction via the fileViaJSON middleware path.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks trailing-dot SVG filename with dangerous Content-Type on binary upload', async () => {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks filename with mixed trailing dots and whitespace', async () => {
|
||||
for (const filename of ['poc.svg..', 'poc.svg. ', 'poc.svg . ']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
url: `http://localhost:8378/1/files/${encodeURIComponent(filename)}`,
|
||||
body: '<svg/>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it('still allows trailing-dot filename with allowed Content-Type', async () => {
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/notes.txt.',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'text/plain',
|
||||
base64: Buffer.from('hello').toString('base64'),
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FilesController treats trailing-dot filename as extensionless when appending derived extension via master key upload', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
preserveFileName: true,
|
||||
});
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
body: '<svg/>',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const filenameArg = spy.calls.mostRecent().args[0];
|
||||
const contentTypeArg = spy.calls.mostRecent().args[2];
|
||||
// Trailing-dot filename is treated as extensionless: derived extension appended without doubling the dot
|
||||
expect(filenameArg).toBe('poc.svg.svg');
|
||||
// Caller-supplied Content-Type is preserved on the extensionless path
|
||||
expect(contentTypeArg).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('allows trailing-dot filename when no Content-Type is supplied (no XSS path)', async () => {
|
||||
// Trailing-dot filename with no caller-supplied Content-Type: the
|
||||
// blocklist gate skips because no extension can be determined, but no
|
||||
// attacker-controlled Content-Type reaches the storage adapter — only
|
||||
// the SDK's benign default — so no stored XSS is possible.
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: '<svg/>',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const contentTypeArg = spy.calls.mostRecent().args[2];
|
||||
expect(contentTypeArg).not.toMatch(/svg|html|xml|xhtml|xslt|mathml/i);
|
||||
});
|
||||
|
||||
it('falls back to raw Content-Type when Content-Type is malformed (no slash)', async () => {
|
||||
// Exercises the last-resort branch: when both the filename has no usable
|
||||
// extension AND the Content-Type lacks a "/" subtype to parse, the raw
|
||||
// Content-Type is used as the extension so a malformed header that
|
||||
// matches a blocked pattern still trips the blocklist.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'svg',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc',
|
||||
body: '<svg/>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension svg is disabled/),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -6151,142 +5983,4 @@ describe('Vulnerabilities', () => {
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-75v4-m273-5j49) _User CLP refetch fallback leaks raw MFA secrets and protected fields', () => {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const denyGetCLP = {
|
||||
get: {},
|
||||
find: {},
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: {},
|
||||
};
|
||||
|
||||
const updateUserCLP = classLevelPermissions =>
|
||||
request({
|
||||
method: 'PUT',
|
||||
url: Parse.serverURL + '/schemas/_User',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ classLevelPermissions }),
|
||||
});
|
||||
|
||||
async function setupMfaUser() {
|
||||
const OTPAuth = require('otpauth');
|
||||
const user = await Parse.User.signUp('victim', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
user.set('phone', '555-1234');
|
||||
await user.save(null, { sessionToken });
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret });
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token: totp.generate() } } },
|
||||
{ sessionToken }
|
||||
);
|
||||
return { user, totp, secret };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
mfa: { enabled: true, options: ['TOTP'], algorithm: 'SHA1', digits: 6, period: 30 },
|
||||
},
|
||||
protectedFields: { _User: { '*': ['phone'] } },
|
||||
protectedFieldsOwnerExempt: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak raw MFA secrets or protected fields from /verifyPassword when _User get CLP denies the re-fetch', async () => {
|
||||
await setupMfaUser();
|
||||
await updateUserCLP(denyGetCLP);
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/verifyPassword',
|
||||
headers,
|
||||
body: JSON.stringify({ username: 'victim', password: 'password' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
// Access control denied the re-fetch, so no stored fields may be disclosed
|
||||
expect(response.data.authData).toBeUndefined();
|
||||
expect(response.data.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not leak raw MFA secrets or protected fields from /login when _User get CLP denies the re-fetch', async () => {
|
||||
const { totp } = await setupMfaUser();
|
||||
await updateUserCLP(denyGetCLP);
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/login',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
username: 'victim',
|
||||
password: 'password',
|
||||
authData: { mfa: { token: totp.generate() } },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Login still succeeds and issues a session for the authenticated user
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
expect(response.data.sessionToken).toBeDefined();
|
||||
// But discloses no stored fields the caller may not read
|
||||
expect(response.data.authData).toBeUndefined();
|
||||
expect(response.data.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sanitizes MFA secrets and protected fields on /verifyPassword when get CLP permits the re-fetch', async () => {
|
||||
await setupMfaUser();
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/verifyPassword',
|
||||
headers,
|
||||
body: JSON.stringify({ username: 'victim', password: 'password' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
// afterFind replaces raw MFA material with a status flag
|
||||
expect(response.data.authData.mfa.status).toBe('enabled');
|
||||
expect(response.data.authData.mfa.secret).toBeUndefined();
|
||||
expect(response.data.authData.mfa.recovery).toBeUndefined();
|
||||
// protectedFieldsOwnerExempt:false strips protected fields even for the owner
|
||||
expect(response.data.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the full user to a master-key /verifyPassword even when get CLP is denied', async () => {
|
||||
await setupMfaUser();
|
||||
await updateUserCLP(denyGetCLP);
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/verifyPassword',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username: 'victim', password: 'password' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
// Master bypasses CLP and protectedFields by design, so it still receives
|
||||
// the full record (auth hierarchy preserved); the minimal denied-path
|
||||
// response only applies to non-master callers.
|
||||
expect(response.data.phone).toBe('555-1234');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { randomHexString } from '../cryptoUtils';
|
||||
import AdaptableController from './AdaptableController';
|
||||
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
||||
import path from 'path';
|
||||
const Parse = require('parse/node').Parse;
|
||||
const Utils = require('../Utils');
|
||||
|
||||
const legacyFilesRegex = new RegExp(
|
||||
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*'
|
||||
@@ -15,13 +15,12 @@ export class FilesController extends AdaptableController {
|
||||
}
|
||||
|
||||
async createFile(config, filename, data, contentType, options) {
|
||||
const extname = Utils.getFileExtension(filename);
|
||||
const extname = path.extname(filename);
|
||||
|
||||
const hasExtension = extname.length > 0;
|
||||
const mime = (await import('mime')).default
|
||||
if (!hasExtension && contentType && mime.getExtension(contentType)) {
|
||||
// Avoid producing a doubled dot when the filename already ends in one
|
||||
const separator = filename.endsWith('.') ? '' : '.';
|
||||
filename = filename + separator + mime.getExtension(contentType);
|
||||
filename = filename + '.' + mime.getExtension(contentType);
|
||||
} else if (hasExtension) {
|
||||
contentType = mime.getType(filename) || contentType;
|
||||
}
|
||||
|
||||
@@ -423,20 +423,14 @@ export class FilesRouter {
|
||||
}
|
||||
});
|
||||
};
|
||||
let extension = Utils.getFileExtension(filename);
|
||||
let extension = contentType;
|
||||
if (filename && filename.includes('.')) {
|
||||
extension = filename.substring(filename.lastIndexOf('.') + 1);
|
||||
} else if (contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1];
|
||||
}
|
||||
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
// If the filename has no usable extension (no dot, trailing dot, or
|
||||
// whitespace-only suffix), fall back to the Content-Type subtype — same
|
||||
// as a dotless filename.
|
||||
if (!extension && contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
// Last resort for malformed inputs (e.g. Content-Type without a slash):
|
||||
// use the raw Content-Type so the existing rejection path still fires.
|
||||
if (!extension && contentType) {
|
||||
extension = contentType.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
if (extension && !isValidExtension(extension)) {
|
||||
next(
|
||||
|
||||
@@ -370,21 +370,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
);
|
||||
filteredUser = filteredUserResponse.results?.[0];
|
||||
} catch {
|
||||
// The re-fetch enforces `_User` `get` CLP and may be denied by access
|
||||
// control (e.g. CLP `get: {}` or an ACL that excludes the caller).
|
||||
// Handled below; never fall back to the raw row.
|
||||
// re-fetch may fail for legacy users without ACL; fall through
|
||||
}
|
||||
if (!filteredUser) {
|
||||
// Master/maintenance callers bypass CLP, protectedFields, and authData
|
||||
// afterFind, so for them an empty re-fetch is a genuine not-found edge, not
|
||||
// an access-control denial; they are entitled to the full row. For every
|
||||
// other caller, an empty/denied re-fetch means access control withheld the
|
||||
// record, so disclose only the identity — never the raw row, which would
|
||||
// leak fields hidden by `protectedFields` and raw `authData` (e.g. MFA
|
||||
// secrets and recovery codes) that the sanitizing re-fetch would remove.
|
||||
// The session token is still attached below so login succeeds.
|
||||
filteredUser =
|
||||
req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
|
||||
filteredUser = user;
|
||||
}
|
||||
UsersRouter.removeHiddenProperties(filteredUser);
|
||||
filteredUser.sessionToken = user.sessionToken;
|
||||
@@ -483,20 +472,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
);
|
||||
filteredUser = filteredUserResponse.results?.[0];
|
||||
} catch {
|
||||
// The re-fetch enforces `_User` `get` CLP and may be denied by access
|
||||
// control (e.g. CLP `get: {}` or an ACL that excludes the caller).
|
||||
// Handled below; never fall back to the raw row.
|
||||
// re-fetch may fail for legacy users without ACL; fall through
|
||||
}
|
||||
if (!filteredUser) {
|
||||
// See handleLogIn: master/maintenance callers bypass CLP,
|
||||
// protectedFields, and authData afterFind, so an empty re-fetch is a
|
||||
// genuine not-found edge for them and they are entitled to the full
|
||||
// row. For all other callers, an empty/denied re-fetch means access
|
||||
// control withheld the record, so disclose only the identity rather
|
||||
// than the raw row, which would leak protectedFields and raw authData
|
||||
// (e.g. MFA secrets and recovery codes).
|
||||
filteredUser =
|
||||
req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
|
||||
filteredUser = user;
|
||||
}
|
||||
UsersRouter.removeHiddenProperties(filteredUser);
|
||||
return { response: filteredUser };
|
||||
|
||||
@@ -576,23 +576,6 @@ class Utils {
|
||||
return Math.floor(num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file extension as the substring after the last dot in the
|
||||
* filename. A trailing dot or a filename without a dot yields an empty
|
||||
* string. Callers apply any further normalization (whitespace, MIME
|
||||
* parameters, etc.) for their use case — this is a pure parser, not a
|
||||
* policy.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {string} the extension, or `''` if none
|
||||
*/
|
||||
static getFileExtension(filename) {
|
||||
if (!filename || !filename.includes('.')) {
|
||||
return '';
|
||||
}
|
||||
return filename.substring(filename.lastIndexOf('.') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Utils;
|
||||
|
||||
Reference in New Issue
Block a user