Skip to content

Commit 59169ba

Browse files
committed
fix
1 parent 97b6b3c commit 59169ba

15 files changed

+142
-78
lines changed

package-lock.json

Lines changed: 0 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"bcryptjs": "3.0.3",
3131
"commander": "14.0.3",
3232
"cors": "2.8.6",
33-
"deepcopy": "2.1.0",
3433
"express": "5.2.1",
3534
"express-rate-limit": "8.2.1",
3635
"follow-redirects": "1.15.9",

spec/vulnerabilities.spec.js

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -353,12 +353,23 @@ describe('Vulnerabilities', () => {
353353
});
354354

355355
it('denies __proto__ after a sibling nested object', async () => {
356-
// Cannot test via HTTP because deepcopy() strips __proto__ before the denylist
357-
// check runs. Test objectContainsKeyValue directly with a JSON.parse'd object
358-
// that preserves __proto__ as an own property.
359-
const Utils = require('../lib/Utils');
360-
const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}');
361-
expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true);
356+
const headers = {
357+
'Content-Type': 'application/json',
358+
'X-Parse-Application-Id': 'test',
359+
'X-Parse-REST-API-Key': 'rest',
360+
};
361+
const response = await request({
362+
headers,
363+
method: 'POST',
364+
url: 'http://localhost:8378/1/classes/PP',
365+
body: JSON.stringify(
366+
JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}')
367+
),
368+
}).catch(e => e);
369+
expect(response.status).toBe(400);
370+
const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
371+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
372+
expect(text.error).toContain('__proto__');
362373
});
363374

364375
it('denies constructor after a sibling nested object', async () => {
@@ -2644,4 +2655,95 @@ describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field na
26442655
expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/);
26452656
});
26462657
});
2658+
2659+
describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => {
2660+
const headers = {
2661+
'Content-Type': 'application/json',
2662+
'X-Parse-Application-Id': 'test',
2663+
'X-Parse-REST-API-Key': 'rest',
2664+
};
2665+
2666+
it('rejects __proto__ in request body via HTTP', async () => {
2667+
const response = await request({
2668+
headers,
2669+
method: 'POST',
2670+
url: 'http://localhost:8378/1/classes/ProtoTest',
2671+
body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')),
2672+
}).catch(e => e);
2673+
expect(response.status).toBe(400);
2674+
const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
2675+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
2676+
expect(text.error).toContain('__proto__');
2677+
});
2678+
2679+
it('does not add fields to a locked schema via __proto__', async () => {
2680+
const schema = new Parse.Schema('LockedSchema');
2681+
schema.addString('name');
2682+
schema.setCLP({
2683+
find: { '*': true },
2684+
get: { '*': true },
2685+
create: { '*': true },
2686+
update: { '*': true },
2687+
delete: { '*': true },
2688+
addField: {},
2689+
});
2690+
await schema.save();
2691+
2692+
// Attempt to inject a field via __proto__
2693+
const response = await request({
2694+
headers,
2695+
method: 'POST',
2696+
url: 'http://localhost:8378/1/classes/LockedSchema',
2697+
body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')),
2698+
}).catch(e => e);
2699+
2700+
// Should be rejected by denylist
2701+
expect(response.status).toBe(400);
2702+
2703+
// Verify schema was not modified
2704+
const schemaResponse = await request({
2705+
headers: {
2706+
'X-Parse-Application-Id': 'test',
2707+
'X-Parse-Master-Key': 'test',
2708+
},
2709+
method: 'GET',
2710+
url: 'http://localhost:8378/1/schemas/LockedSchema',
2711+
});
2712+
const fields = schemaResponse.data.fields;
2713+
expect(fields.newField).toBeUndefined();
2714+
});
2715+
2716+
it('does not cause schema type conflict via __proto__', async () => {
2717+
const schema = new Parse.Schema('TypeConflict');
2718+
schema.addString('name');
2719+
schema.addString('score');
2720+
schema.setCLP({
2721+
find: { '*': true },
2722+
get: { '*': true },
2723+
create: { '*': true },
2724+
update: { '*': true },
2725+
delete: { '*': true },
2726+
addField: {},
2727+
});
2728+
await schema.save();
2729+
2730+
// Attempt to inject 'score' as Number via __proto__
2731+
const response = await request({
2732+
headers,
2733+
method: 'POST',
2734+
url: 'http://localhost:8378/1/classes/TypeConflict',
2735+
body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')),
2736+
}).catch(e => e);
2737+
2738+
// Should be rejected by denylist
2739+
expect(response.status).toBe(400);
2740+
2741+
// Verify 'score' field is still String type
2742+
const obj = new Parse.Object('TypeConflict');
2743+
obj.set('name', 'valid');
2744+
obj.set('score', 'string-value');
2745+
await obj.save();
2746+
expect(obj.get('score')).toBe('string-value');
2747+
});
2748+
});
26472749
});

src/Controllers/DatabaseController.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import { Parse } from 'parse/node';
88
import _ from 'lodash';
99
// @flow-disable-next
1010
import intersect from 'intersect';
11-
// @flow-disable-next
12-
import deepcopy from 'deepcopy';
1311
import logger from '../logger';
1412
import Utils from '../Utils';
1513
import * as SchemaController from './SchemaController';
@@ -541,7 +539,7 @@ class DatabaseController {
541539
const originalQuery = query;
542540
const originalUpdate = update;
543541
// Make a copy of the object, so we don't mutate the incoming data.
544-
update = deepcopy(update);
542+
update = structuredClone(update);
545543
var relationUpdates = [];
546544
var isMaster = acl === undefined;
547545
var aclGroup = acl || [];

src/Controllers/SchemaController.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import SchemaCache from '../Adapters/Cache/SchemaCache';
2121
import DatabaseController from './DatabaseController';
2222
import Config from '../Config';
2323
import { createSanitizedError } from '../Error';
24-
// @flow-disable-next
25-
import deepcopy from 'deepcopy';
2624
import type {
2725
Schema,
2826
SchemaFields,
@@ -573,7 +571,7 @@ class SchemaData {
573571
if (!this.__data[schema.className]) {
574572
const data = {};
575573
data.fields = injectDefaultSchema(schema).fields;
576-
data.classLevelPermissions = deepcopy(schema.classLevelPermissions);
574+
data.classLevelPermissions = structuredClone(schema.classLevelPermissions);
577575
data.indexes = schema.indexes;
578576

579577
const classProtectedFields = this.__protectedFields[schema.className];

src/GraphQL/loaders/functionsMutations.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { GraphQLNonNull, GraphQLEnumType } from 'graphql';
2-
import deepcopy from 'deepcopy';
2+
33
import { mutationWithClientMutationId } from 'graphql-relay';
44
import { FunctionsRouter } from '../../Routers/FunctionsRouter';
55
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
@@ -44,7 +44,7 @@ const load = parseGraphQLSchema => {
4444
},
4545
mutateAndGetPayload: async (args, context) => {
4646
try {
47-
const { functionName, params } = deepcopy(args);
47+
const { functionName, params } = structuredClone(args);
4848
const { config, auth, info } = context;
4949

5050
return {

src/GraphQL/loaders/parseClassMutations.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { GraphQLNonNull } from 'graphql';
22
import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay';
33
import getFieldNames from 'graphql-list-fields';
4-
import deepcopy from 'deepcopy';
4+
55
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
66
import { extractKeysAndInclude, getParseClassMutationConfig } from '../parseGraphQLUtils';
77
import * as objectsMutations from '../helpers/objectsMutations';
@@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
7575
},
7676
mutateAndGetPayload: async (args, context, mutationInfo) => {
7777
try {
78-
let { fields } = deepcopy(args);
78+
let { fields } = structuredClone(args);
7979
if (!fields) { fields = {}; }
8080
const { config, auth, info } = context;
8181

@@ -178,7 +178,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
178178
},
179179
mutateAndGetPayload: async (args, context, mutationInfo) => {
180180
try {
181-
let { id, fields } = deepcopy(args);
181+
let { id, fields } = structuredClone(args);
182182
if (!fields) { fields = {}; }
183183
const { config, auth, info } = context;
184184

@@ -284,7 +284,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
284284
},
285285
mutateAndGetPayload: async (args, context, mutationInfo) => {
286286
try {
287-
let { id } = deepcopy(args);
287+
let { id } = structuredClone(args);
288288
const { config, auth, info } = context;
289289

290290
const globalIdObject = fromGlobalId(id);

src/GraphQL/loaders/parseClassQueries.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { GraphQLNonNull } from 'graphql';
22
import { fromGlobalId } from 'graphql-relay';
33
import getFieldNames from 'graphql-list-fields';
4-
import deepcopy from 'deepcopy';
4+
55
import pluralize from 'pluralize';
66
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
77
import * as objectsQueries from '../helpers/objectsQueries';
@@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
7575
return await getQuery(
7676
parseClass,
7777
_source,
78-
deepcopy(args),
78+
structuredClone(args),
7979
context,
8080
queryInfo,
8181
parseGraphQLSchema.parseClasses
@@ -99,7 +99,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
9999
async resolve(_source, args, context, queryInfo) {
100100
try {
101101
// Deep copy args to avoid internal re assign issue
102-
const { where, order, skip, first, after, last, before, options } = deepcopy(args);
102+
const { where, order, skip, first, after, last, before, options } = structuredClone(args);
103103
const { readPreference, includeReadPreference, subqueryReadPreference } = options || {};
104104
const { config, auth, info } = context;
105105
const selectedFields = getFieldNames(queryInfo);

src/GraphQL/loaders/schemaMutations.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Parse from 'parse/node';
22
import { GraphQLNonNull } from 'graphql';
3-
import deepcopy from 'deepcopy';
3+
44
import { mutationWithClientMutationId } from 'graphql-relay';
55
import * as schemaTypes from './schemaTypes';
66
import { transformToParse, transformToGraphQL } from '../transformers/schemaFields';
@@ -28,7 +28,7 @@ const load = parseGraphQLSchema => {
2828
},
2929
mutateAndGetPayload: async (args, context) => {
3030
try {
31-
const { name, schemaFields } = deepcopy(args);
31+
const { name, schemaFields } = structuredClone(args);
3232
const { config, auth } = context;
3333

3434
enforceMasterKeyAccess(auth, config);
@@ -78,7 +78,7 @@ const load = parseGraphQLSchema => {
7878
},
7979
mutateAndGetPayload: async (args, context) => {
8080
try {
81-
const { name, schemaFields } = deepcopy(args);
81+
const { name, schemaFields } = structuredClone(args);
8282
const { config, auth } = context;
8383

8484
enforceMasterKeyAccess(auth, config);
@@ -130,7 +130,7 @@ const load = parseGraphQLSchema => {
130130
},
131131
mutateAndGetPayload: async (args, context) => {
132132
try {
133-
const { name } = deepcopy(args);
133+
const { name } = structuredClone(args);
134134
const { config, auth } = context;
135135

136136
enforceMasterKeyAccess(auth, config);

src/GraphQL/loaders/schemaQueries.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Parse from 'parse/node';
2-
import deepcopy from 'deepcopy';
2+
33
import { GraphQLNonNull, GraphQLList } from 'graphql';
44
import { transformToGraphQL } from '../transformers/schemaFields';
55
import * as schemaTypes from './schemaTypes';
@@ -28,7 +28,7 @@ const load = parseGraphQLSchema => {
2828
type: new GraphQLNonNull(schemaTypes.CLASS),
2929
resolve: async (_source, args, context) => {
3030
try {
31-
const { name } = deepcopy(args);
31+
const { name } = structuredClone(args);
3232
const { config, auth } = context;
3333

3434
enforceMasterKeyAccess(auth, config);

0 commit comments

Comments
 (0)