Skip to content

Commit 60a58ec

Browse files
authored
fix: Context mutations leak across requests in ParseServerRESTController (#10291)
1 parent 7c3b43d commit 60a58ec

File tree

2 files changed

+104
-1
lines changed

2 files changed

+104
-1
lines changed

spec/ParseServerRESTController.spec.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,97 @@ describe('ParseServerRESTController', () => {
519519
);
520520
});
521521

522+
it('should deep copy context so mutations in beforeSave do not leak across requests', async () => {
523+
const sharedContext = { counter: 0, nested: { value: 'original' } };
524+
525+
Parse.Cloud.beforeSave('ContextTestObject', req => {
526+
// Mutate the context in beforeSave
527+
req.context.counter = (req.context.counter || 0) + 1;
528+
req.context.nested.value = 'mutated';
529+
req.context.addedByHook = true;
530+
});
531+
532+
// First save — this should not affect the original sharedContext
533+
await RESTController.request(
534+
'POST',
535+
'/classes/ContextTestObject',
536+
{ key: 'value1' },
537+
{ context: sharedContext }
538+
);
539+
540+
// The original context object must remain unchanged
541+
expect(sharedContext.counter).toEqual(0);
542+
expect(sharedContext.nested.value).toEqual('original');
543+
expect(sharedContext.addedByHook).toBeUndefined();
544+
545+
// Second save with the same context — should also start with the original values
546+
await RESTController.request(
547+
'POST',
548+
'/classes/ContextTestObject',
549+
{ key: 'value2' },
550+
{ context: sharedContext }
551+
);
552+
553+
// The original context object must still remain unchanged
554+
expect(sharedContext.counter).toEqual(0);
555+
expect(sharedContext.nested.value).toEqual('original');
556+
expect(sharedContext.addedByHook).toBeUndefined();
557+
});
558+
559+
it('should isolate context between concurrent requests', async () => {
560+
const contexts = [];
561+
562+
Parse.Cloud.beforeSave('ConcurrentContextObject', req => {
563+
// Each request should see its own context, not a shared one
564+
req.context.requestId = req.object.get('requestId');
565+
contexts.push({ ...req.context });
566+
});
567+
568+
const sharedContext = { shared: true };
569+
570+
await Promise.all([
571+
RESTController.request(
572+
'POST',
573+
'/classes/ConcurrentContextObject',
574+
{ requestId: 'req1' },
575+
{ context: sharedContext }
576+
),
577+
RESTController.request(
578+
'POST',
579+
'/classes/ConcurrentContextObject',
580+
{ requestId: 'req2' },
581+
{ context: sharedContext }
582+
),
583+
]);
584+
585+
// Each hook should have seen its own requestId, not the other's
586+
const req1Context = contexts.find(c => c.requestId === 'req1');
587+
const req2Context = contexts.find(c => c.requestId === 'req2');
588+
expect(req1Context).toBeDefined();
589+
expect(req2Context).toBeDefined();
590+
expect(req1Context.requestId).toEqual('req1');
591+
expect(req2Context.requestId).toEqual('req2');
592+
// Original context must remain unchanged
593+
expect(sharedContext.requestId).toBeUndefined();
594+
});
595+
596+
it('should reject with an error when context contains non-cloneable values', async () => {
597+
const nonCloneableContext = { fn: () => {} };
598+
try {
599+
await RESTController.request(
600+
'POST',
601+
'/classes/MyObject',
602+
{ key: 'value' },
603+
{ context: nonCloneableContext }
604+
);
605+
fail('should have rejected for non-cloneable context');
606+
} catch (error) {
607+
expect(error).toBeDefined();
608+
expect(error.code).toEqual(Parse.Error.INVALID_VALUE);
609+
expect(error.message).toContain('Context contains non-cloneable values');
610+
}
611+
});
612+
522613
it('ensures sessionTokens are properly handled', async () => {
523614
const user = await Parse.User.signUp('user', 'pass');
524615
const sessionToken = user.getSessionToken();

src/ParseServerRESTController.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ function ParseServerRESTController(applicationId, router) {
111111
}
112112

113113
return new Promise((resolve, reject) => {
114+
let requestContext;
115+
try {
116+
requestContext = structuredClone(options.context || {});
117+
} catch (error) {
118+
reject(
119+
new Parse.Error(
120+
Parse.Error.INVALID_VALUE,
121+
`Context contains non-cloneable values: ${error.message}`
122+
)
123+
);
124+
return;
125+
}
114126
getAuth(options, config).then(auth => {
115127
const request = {
116128
body: data,
@@ -120,7 +132,7 @@ function ParseServerRESTController(applicationId, router) {
120132
applicationId: applicationId,
121133
sessionToken: options.sessionToken,
122134
installationId: options.installationId,
123-
context: options.context || {},
135+
context: requestContext,
124136
},
125137
query,
126138
};

0 commit comments

Comments
 (0)