Skip to content

Commit 8b36e1e

Browse files
authored
Merge pull request #3893 from Nixxx19/fix-mass-assignment-updateProject-3875
Fix: mass assignment update project #3875
2 parents 71e272e + e6079ed commit 8b36e1e

3 files changed

Lines changed: 154 additions & 3 deletions

File tree

server/controllers/project.controller.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,18 @@ export async function updateProject(req, res) {
4848
});
4949
return;
5050
}
51+
// only allow whitelisted fields so ownership/slug etc can't be overwritten
52+
const allowedFields = ['name', 'files', 'updatedAt', 'visibility'];
53+
const updateData = {};
54+
allowedFields.forEach((field) => {
55+
if (req.body[field] !== undefined) {
56+
updateData[field] = req.body[field];
57+
}
58+
});
5159
const updatedProject = await Project.findByIdAndUpdate(
5260
req.params.project_id,
5361
{
54-
$set: req.body
62+
$set: updateData
5563
},
5664
{
5765
new: true,
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import { Request, Response } from 'jest-express';
5+
6+
import Project from '../../../models/project';
7+
import { updateProject } from '../../project.controller';
8+
9+
jest.mock('../../../models/project');
10+
11+
describe('project.controller', () => {
12+
describe('updateProject()', () => {
13+
let request;
14+
let response;
15+
16+
beforeEach(() => {
17+
request = new Request();
18+
response = new Response();
19+
response.send = jest.fn();
20+
response.json = jest.fn();
21+
response.status = jest.fn().mockReturnThis();
22+
Project.findById = jest.fn().mockReturnValue({ exec: jest.fn() });
23+
Project.findByIdAndUpdate = jest.fn();
24+
jest.clearAllMocks();
25+
});
26+
27+
afterEach(() => {
28+
request.resetMocked();
29+
response.resetMocked();
30+
});
31+
32+
it('returns 404 if project does not exist', async () => {
33+
request.setParams({ project_id: 'some-id' });
34+
request.user = { _id: 'owner-id' };
35+
request.body = { name: 'Test' };
36+
37+
Project.findById().exec.mockResolvedValue(null);
38+
39+
await updateProject(request, response);
40+
41+
expect(response.status).toHaveBeenCalledWith(404);
42+
expect(response.send).toHaveBeenCalledWith({
43+
success: false,
44+
message: 'Project with that id does not exist.'
45+
});
46+
expect(Project.findByIdAndUpdate).not.toHaveBeenCalled();
47+
});
48+
49+
it('returns 403 if project is not owned by authenticated user', async () => {
50+
const project = {
51+
user: { equals: jest.fn().mockReturnValue(false) }
52+
};
53+
request.setParams({ project_id: 'some-id' });
54+
request.user = { _id: 'other-user-id' };
55+
request.body = { name: 'Test' };
56+
57+
Project.findById().exec.mockResolvedValue(project);
58+
59+
await updateProject(request, response);
60+
61+
expect(response.status).toHaveBeenCalledWith(403);
62+
expect(response.send).toHaveBeenCalledWith({
63+
success: false,
64+
message: 'Session does not match owner of project.'
65+
});
66+
expect(Project.findByIdAndUpdate).not.toHaveBeenCalled();
67+
});
68+
69+
it('only updates whitelisted fields (user and slug in body are ignored)', async () => {
70+
const ownerId = 'owner-123';
71+
const project = {
72+
_id: 'proj-1',
73+
user: { equals: (id) => id === ownerId },
74+
updatedAt: new Date('2024-01-01'),
75+
files: []
76+
};
77+
const updatedDoc = {
78+
_id: 'proj-1',
79+
user: ownerId,
80+
name: 'Updated name',
81+
slug: 'original-slug',
82+
files: []
83+
};
84+
85+
request.setParams({ project_id: 'proj-1' });
86+
request.user = { _id: ownerId };
87+
request.body = {
88+
name: 'Updated name',
89+
user: 'another-user-id',
90+
slug: 'hacked-slug'
91+
};
92+
93+
Project.findById().exec.mockResolvedValue(project);
94+
Project.findByIdAndUpdate.mockReturnValue({
95+
populate: jest.fn().mockReturnThis(),
96+
exec: jest.fn().mockResolvedValue(updatedDoc)
97+
});
98+
99+
await updateProject(request, response);
100+
101+
expect(Project.findByIdAndUpdate).toHaveBeenCalled();
102+
const updateArg = Project.findByIdAndUpdate.mock.calls[0][1];
103+
const setPayload = updateArg.$set;
104+
105+
expect(setPayload).toHaveProperty('name', 'Updated name');
106+
expect(setPayload).not.toHaveProperty('user');
107+
expect(setPayload).not.toHaveProperty('slug');
108+
});
109+
110+
it('allows updating whitelisted fields (name, visibility, updatedAt)', async () => {
111+
const ownerId = 'owner-123';
112+
const project = {
113+
_id: 'proj-1',
114+
user: { equals: (id) => id === ownerId },
115+
updatedAt: new Date('2024-01-01'),
116+
files: []
117+
};
118+
119+
request.setParams({ project_id: 'proj-1' });
120+
request.user = { _id: ownerId };
121+
request.body = {
122+
name: 'New name',
123+
visibility: 'Private',
124+
updatedAt: new Date('2024-01-02')
125+
};
126+
127+
Project.findById().exec.mockResolvedValue(project);
128+
Project.findByIdAndUpdate.mockReturnValue({
129+
populate: jest.fn().mockReturnThis(),
130+
exec: jest.fn().mockResolvedValue({})
131+
});
132+
133+
await updateProject(request, response);
134+
135+
const updateArg = Project.findByIdAndUpdate.mock.calls[0][1];
136+
const setPayload = updateArg.$set;
137+
138+
expect(setPayload).toHaveProperty('name', 'New name');
139+
expect(setPayload).toHaveProperty('visibility', 'Private');
140+
expect(setPayload).toHaveProperty('updatedAt');
141+
});
142+
});
143+
});

server/models/project.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const projectSchema = new Schema(
3434
default: "Hello p5.js, it's the server",
3535
maxlength: 128
3636
},
37-
user: { type: Schema.Types.ObjectId, ref: 'User' },
37+
user: { type: Schema.Types.ObjectId, ref: 'User', immutable: true },
3838
serveSecure: { type: Boolean, default: false },
3939
files: { type: [fileSchema] },
4040
_id: { type: String, default: shortid.generate },
@@ -43,7 +43,7 @@ const projectSchema = new Schema(
4343
enum: ['Private', 'Public'],
4444
default: 'Public'
4545
},
46-
slug: { type: String }
46+
slug: { type: String, immutable: true }
4747
},
4848
{ timestamps: true }
4949
);

0 commit comments

Comments
 (0)