Compare commits

..
Author SHA1 Message Date
GitHub Actions c692c3e6e6 empty commit to trigger CI 2026-06-01 00:46:13 +00:00
9 changed files with 17 additions and 403 deletions
-14
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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": {
-21
View File
@@ -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);
});
}
});
});
-306
View File
@@ -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');
});
});
});
+4 -5
View File
@@ -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;
}
+6 -12
View File
@@ -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(
+4 -25
View File
@@ -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 };
-17
View File
@@ -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;