fix: Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) (#10201)

This commit is contained in:
Manuel
2026-03-14 16:01:25 +01:00
committed by GitHub
parent 1a547771cc
commit 6aec8ea9e1
16 changed files with 157 additions and 82 deletions
-30
View File
@@ -19,7 +19,6 @@
"bcryptjs": "3.0.2",
"commander": "13.1.0",
"cors": "2.8.5",
"deepcopy": "2.1.0",
"express": "5.2.1",
"express-rate-limit": "7.5.1",
"follow-redirects": "1.15.9",
@@ -9541,14 +9540,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/deepcopy": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz",
"integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==",
"dependencies": {
"type-detect": "^4.0.8"
}
},
"node_modules/default-require-extensions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
@@ -22164,14 +22155,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
"engines": {
"node": ">=4"
}
},
"node_modules/type-fest": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz",
@@ -29757,14 +29740,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"deepcopy": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz",
"integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==",
"requires": {
"type-detect": "^4.0.8"
}
},
"default-require-extensions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
@@ -38597,11 +38572,6 @@
"prelude-ls": "^1.2.1"
}
},
"type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="
},
"type-fest": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz",
-1
View File
@@ -29,7 +29,6 @@
"bcryptjs": "3.0.2",
"commander": "13.1.0",
"cors": "2.8.5",
"deepcopy": "2.1.0",
"express": "5.2.1",
"express-rate-limit": "7.5.1",
"follow-redirects": "1.15.9",
+108 -6
View File
@@ -277,12 +277,23 @@ describe('Vulnerabilities', () => {
});
it('denies __proto__ after a sibling nested object', async () => {
// Cannot test via HTTP because deepcopy() strips __proto__ before the denylist
// check runs. Test objectContainsKeyValue directly with a JSON.parse'd object
// that preserves __proto__ as an own property.
const Utils = require('../lib/Utils');
const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}');
expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true);
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/PP',
body: JSON.stringify(
JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}')
),
}).catch(e => e);
expect(response.status).toBe(400);
const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toContain('__proto__');
});
it('denies constructor after a sibling nested object', async () => {
@@ -2510,4 +2521,95 @@ describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Conte
}));
}
});
describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
it('rejects __proto__ in request body via HTTP', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/ProtoTest',
body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')),
}).catch(e => e);
expect(response.status).toBe(400);
const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toContain('__proto__');
});
it('does not add fields to a locked schema via __proto__', async () => {
const schema = new Parse.Schema('LockedSchema');
schema.addString('name');
schema.setCLP({
find: { '*': true },
get: { '*': true },
create: { '*': true },
update: { '*': true },
delete: { '*': true },
addField: {},
});
await schema.save();
// Attempt to inject a field via __proto__
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/LockedSchema',
body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')),
}).catch(e => e);
// Should be rejected by denylist
expect(response.status).toBe(400);
// Verify schema was not modified
const schemaResponse = await request({
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
method: 'GET',
url: 'http://localhost:8378/1/schemas/LockedSchema',
});
const fields = schemaResponse.data.fields;
expect(fields.newField).toBeUndefined();
});
it('does not cause schema type conflict via __proto__', async () => {
const schema = new Parse.Schema('TypeConflict');
schema.addString('name');
schema.addString('score');
schema.setCLP({
find: { '*': true },
get: { '*': true },
create: { '*': true },
update: { '*': true },
delete: { '*': true },
addField: {},
});
await schema.save();
// Attempt to inject 'score' as Number via __proto__
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/TypeConflict',
body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')),
}).catch(e => e);
// Should be rejected by denylist
expect(response.status).toBe(400);
// Verify 'score' field is still String type
const obj = new Parse.Object('TypeConflict');
obj.set('name', 'valid');
obj.set('score', 'string-value');
await obj.save();
expect(obj.get('score')).toBe('string-value');
});
});
});
+1 -3
View File
@@ -8,8 +8,6 @@ import { Parse } from 'parse/node';
import _ from 'lodash';
// @flow-disable-next
import intersect from 'intersect';
// @flow-disable-next
import deepcopy from 'deepcopy';
import logger from '../logger';
import Utils from '../Utils';
import * as SchemaController from './SchemaController';
@@ -505,7 +503,7 @@ class DatabaseController {
const originalQuery = query;
const originalUpdate = update;
// Make a copy of the object, so we don't mutate the incoming data.
update = deepcopy(update);
update = structuredClone(update);
var relationUpdates = [];
var isMaster = acl === undefined;
var aclGroup = acl || [];
+1 -3
View File
@@ -21,8 +21,6 @@ import SchemaCache from '../Adapters/Cache/SchemaCache';
import DatabaseController from './DatabaseController';
import Config from '../Config';
import { createSanitizedError } from '../Error';
// @flow-disable-next
import deepcopy from 'deepcopy';
import type {
Schema,
SchemaFields,
@@ -573,7 +571,7 @@ class SchemaData {
if (!this.__data[schema.className]) {
const data = {};
data.fields = injectDefaultSchema(schema).fields;
data.classLevelPermissions = deepcopy(schema.classLevelPermissions);
data.classLevelPermissions = structuredClone(schema.classLevelPermissions);
data.indexes = schema.indexes;
const classProtectedFields = this.__protectedFields[schema.className];
+3 -2
View File
@@ -1,6 +1,7 @@
import { GraphQLNonNull, GraphQLEnumType } from 'graphql';
import deepcopy from 'deepcopy';
import { mutationWithClientMutationId } from 'graphql-relay';
import { cloneArgs } from '../parseGraphQLUtils';
import { FunctionsRouter } from '../../Routers/FunctionsRouter';
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
@@ -44,7 +45,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { functionName, params } = deepcopy(args);
const { functionName, params } = cloneArgs(args);
const { config, auth, info } = context;
return {
+5 -5
View File
@@ -1,9 +1,9 @@
import { GraphQLNonNull } from 'graphql';
import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay';
import getFieldNames from 'graphql-list-fields';
import deepcopy from 'deepcopy';
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
import { extractKeysAndInclude, getParseClassMutationConfig } from '../parseGraphQLUtils';
import { extractKeysAndInclude, getParseClassMutationConfig, cloneArgs } from '../parseGraphQLUtils';
import * as objectsMutations from '../helpers/objectsMutations';
import * as objectsQueries from '../helpers/objectsQueries';
import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController';
@@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
let { fields } = deepcopy(args);
let { fields } = cloneArgs(args);
if (!fields) { fields = {}; }
const { config, auth, info } = context;
@@ -178,7 +178,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
let { id, fields } = deepcopy(args);
let { id, fields } = cloneArgs(args);
if (!fields) { fields = {}; }
const { config, auth, info } = context;
@@ -284,7 +284,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
let { id } = deepcopy(args);
let { id } = cloneArgs(args);
const { config, auth, info } = context;
const globalIdObject = fromGlobalId(id);
+4 -4
View File
@@ -1,13 +1,13 @@
import { GraphQLNonNull } from 'graphql';
import { fromGlobalId } from 'graphql-relay';
import getFieldNames from 'graphql-list-fields';
import deepcopy from 'deepcopy';
import pluralize from 'pluralize';
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
import * as objectsQueries from '../helpers/objectsQueries';
import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController';
import { transformClassNameToGraphQL } from '../transformers/className';
import { extractKeysAndInclude } from '../parseGraphQLUtils';
import { extractKeysAndInclude, cloneArgs } from '../parseGraphQLUtils';
const getParseClassQueryConfig = function (parseClassConfig: ?ParseGraphQLClassConfig) {
return (parseClassConfig && parseClassConfig.query) || {};
@@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
return await getQuery(
parseClass,
_source,
deepcopy(args),
cloneArgs(args),
context,
queryInfo,
parseGraphQLSchema.parseClasses
@@ -99,7 +99,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
async resolve(_source, args, context, queryInfo) {
try {
// Deep copy args to avoid internal re assign issue
const { where, order, skip, first, after, last, before, options } = deepcopy(args);
const { where, order, skip, first, after, last, before, options } = cloneArgs(args);
const { readPreference, includeReadPreference, subqueryReadPreference } = options || {};
const { config, auth, info } = context;
const selectedFields = getFieldNames(queryInfo);
+5 -5
View File
@@ -1,10 +1,10 @@
import Parse from 'parse/node';
import { GraphQLNonNull } from 'graphql';
import deepcopy from 'deepcopy';
import { mutationWithClientMutationId } from 'graphql-relay';
import * as schemaTypes from './schemaTypes';
import { transformToParse, transformToGraphQL } from '../transformers/schemaFields';
import { enforceMasterKeyAccess } from '../parseGraphQLUtils';
import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils';
import { getClass } from './schemaQueries';
import { createSanitizedError } from '../../Error';
@@ -28,7 +28,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { name, schemaFields } = deepcopy(args);
const { name, schemaFields } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
@@ -78,7 +78,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { name, schemaFields } = deepcopy(args);
const { name, schemaFields } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
@@ -130,7 +130,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { name } = deepcopy(args);
const { name } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
+3 -3
View File
@@ -1,9 +1,9 @@
import Parse from 'parse/node';
import deepcopy from 'deepcopy';
import { GraphQLNonNull, GraphQLList } from 'graphql';
import { transformToGraphQL } from '../transformers/schemaFields';
import * as schemaTypes from './schemaTypes';
import { enforceMasterKeyAccess } from '../parseGraphQLUtils';
import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils';
const getClass = async (name, schema) => {
try {
@@ -28,7 +28,7 @@ const load = parseGraphQLSchema => {
type: new GraphQLNonNull(schemaTypes.CLASS),
resolve: async (_source, args, context) => {
try {
const { name } = deepcopy(args);
const { name } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
+5 -4
View File
@@ -1,6 +1,7 @@
import { GraphQLNonNull, GraphQLString, GraphQLBoolean, GraphQLInputObjectType } from 'graphql';
import { mutationWithClientMutationId } from 'graphql-relay';
import deepcopy from 'deepcopy';
import { cloneArgs } from '../parseGraphQLUtils';
import UsersRouter from '../../Routers/UsersRouter';
import * as objectsMutations from '../helpers/objectsMutations';
import { OBJECT } from './defaultGraphQLTypes';
@@ -32,7 +33,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
const { fields } = deepcopy(args);
const { fields } = cloneArgs(args);
const { config, auth, info } = context;
const parseFields = await transformTypes('create', fields, {
@@ -109,7 +110,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
const { fields, authData } = deepcopy(args);
const { fields, authData } = cloneArgs(args);
const { config, auth, info } = context;
const parseFields = await transformTypes('create', fields, {
@@ -173,7 +174,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
const { username, password, authData } = deepcopy(args);
const { username, password, authData } = cloneArgs(args);
const { config, auth, info } = context;
const { sessionToken, objectId, authDataResponse } = (
+8
View File
@@ -55,3 +55,11 @@ export const extractKeysAndInclude = selectedFields => {
export const getParseClassMutationConfig = function (parseClassConfig) {
return (parseClassConfig && parseClassConfig.mutation) || {};
};
export function cloneArgs(args) {
try {
return structuredClone(args);
} catch {
return JSON.parse(JSON.stringify(args));
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ import { LRUCache as LRU } from 'lru-cache';
import UserRouter from '../Routers/UsersRouter';
import DatabaseController from '../Controllers/DatabaseController';
import { isDeepStrictEqual } from 'util';
import deepcopy from 'deepcopy';
class ParseLiveQueryServer {
server: any;
@@ -585,7 +585,7 @@ class ParseLiveQueryServer {
if (!parseObject) {
return false;
}
return matchesQuery(deepcopy(parseObject), subscription.query);
return matchesQuery(structuredClone(parseObject), subscription.query);
}
async _clearCachedRoles(userId: string) {
+1 -3
View File
@@ -1,6 +1,4 @@
// @flow
// @flow-disable-next
import deepcopy from 'deepcopy';
import AdaptableController from '../Controllers/AdaptableController';
import { master } from '../Auth';
import Config from '../Config';
@@ -91,7 +89,7 @@ export class PushWorker {
// Map the on the badges count and return the send result
const promises = Object.keys(badgeInstallationsMap).map(badge => {
const payload = deepcopy(body);
const payload = structuredClone(body);
payload.data.badge = parseInt(badge);
const installations = badgeInstallationsMap[badge];
return this.sendToAdapter(payload, installations, pushStatus, config, UTCOffset);
+3 -3
View File
@@ -1,5 +1,5 @@
import Parse from 'parse/node';
import deepcopy from 'deepcopy';
export function isPushIncrementing(body) {
if (!body.data || !body.data.badge) {
@@ -45,7 +45,7 @@ export function transformPushBodyForLocale(body, locale) {
if (!data) {
return body;
}
body = deepcopy(body);
body = structuredClone(body);
localizableKeys.forEach(key => {
const localeValue = body.data[`${key}-${locale}`];
if (localeValue) {
@@ -128,7 +128,7 @@ export function validatePushType(where = {}, validPushTypes = []) {
}
export function applyDeviceTokenExists(where) {
where = deepcopy(where);
where = structuredClone(where);
if (!Object.prototype.hasOwnProperty.call(where, 'deviceToken')) {
where['deviceToken'] = { $exists: true };
}
+8 -8
View File
@@ -3,7 +3,7 @@
// This could be either a "create" or an "update".
var SchemaController = require('./Controllers/SchemaController');
var deepcopy = require('deepcopy');
const Auth = require('./Auth');
const Utils = require('./Utils');
@@ -75,8 +75,8 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
// Processing this operation may mutate our data, so we operate on a
// copy
this.query = deepcopy(query);
this.data = deepcopy(data);
this.query = structuredClone(query);
this.data = structuredClone(data);
// We never change originalData, so we do not need a deep copy
this.originalData = originalData;
@@ -377,10 +377,10 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function () {
JSON.stringify(schema.classLevelPermissions.ACL) !==
JSON.stringify({ '*': { read: true, write: true } })
) {
const acl = deepcopy(schema.classLevelPermissions.ACL);
const acl = structuredClone(schema.classLevelPermissions.ACL);
if (acl.currentUser) {
if (this.auth.user?.id) {
acl[this.auth.user?.id] = deepcopy(acl.currentUser);
acl[this.auth.user?.id] = structuredClone(acl.currentUser);
}
delete acl.currentUser;
}
@@ -609,7 +609,7 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// Run beforeLogin hook before storing any updates
// to authData on the db; changes to userResult
// will be ignored.
await this.runBeforeLoginTrigger(deepcopy(userResult));
await this.runBeforeLoginTrigger(structuredClone(userResult));
// If we are in login operation via authData
// we need to be sure that the user has provided
@@ -1754,7 +1754,7 @@ RestWrite.prototype.sanitizedData = function () {
delete data[key];
}
return data;
}, deepcopy(this.data));
}, structuredClone(this.data));
return Parse._decode(undefined, data);
};
@@ -1804,7 +1804,7 @@ RestWrite.prototype.buildParseObjects = function () {
delete data[key];
}
return data;
}, deepcopy(this.data));
}, structuredClone(this.data));
const sanitized = this.sanitizedData();
for (const attribute of readOnlyAttributes) {