Skip to content

Commit 8c7adab

Browse files
committed
Prevent impersonation of other users when casting votes. 2025 -> 2026
1 parent 9cf1639 commit 8c7adab

11 files changed

Lines changed: 198 additions & 40 deletions

File tree

backend/src/controllers/rating-controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ export class RatingController {
5555
@CurrentUser() user: User,
5656
): Promise<RatingDTO> {
5757
const rating = convertBetweenEntityAndDTO(ratingDTO, Rating);
58+
59+
// Ensure ratings cannot be cast for other users,
60+
// write the requesting user into it.
61+
rating.user = user;
62+
5863
const createdRating = await this._ratings.createRating(rating, user);
5964
return convertBetweenEntityAndDTO(createdRating, RatingDTO);
6065
}

backend/src/services/rating-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,5 +217,9 @@ export class RatingService implements IRatingService {
217217
if (team.users.includes(user.id.toString())) {
218218
throw new ForbiddenError("You can't rate your own project")
219219
}
220+
221+
if (rating.user.id !== user.id) {
222+
throw new ForbiddenError("You can't rate as a different user");
223+
}
220224
}
221225
}

backend/test/controllers/rating-controller.spec.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ describe("RatingController", () => {
4848
describe("authorization", () => {
4949
let server: http.Server;
5050
let port: number;
51-
let httpRatingService: MockedService<IRatingService>;
52-
let httpSettingsService: MockedService<ISettingsService>;
53-
let httpUserService: MockedService<IUserService>;
51+
let ratingService: MockedService<IRatingService>;
52+
let settingsService: MockedService<ISettingsService>;
53+
let userService: MockedService<IUserService>;
5454

5555
const rootUser = Object.assign(new User(), { id: 1, role: UserRole.Root });
5656
const regularUser = Object.assign(new User(), { id: 2, role: UserRole.User });
@@ -61,20 +61,20 @@ describe("RatingController", () => {
6161
};
6262

6363
beforeAll(async () => {
64-
httpRatingService = new MockRatingService();
65-
httpSettingsService = new MockSettingsService();
66-
httpUserService = new MockUserService();
64+
ratingService = new MockRatingService();
65+
settingsService = new MockSettingsService();
66+
userService = new MockUserService();
6767

68-
httpUserService.mocks.findUserByLoginToken.mockImplementation(
68+
userService.mocks.findUserByLoginToken.mockImplementation(
6969
async (token: string) => tokenMap[token] ?? null,
7070
);
7171

72-
const httpService = new HttpService(null as any, null as any, httpUserService.instance);
72+
const httpService = new HttpService(null as any, null as any, userService.instance);
7373

7474
useContainer({
7575
get(target: Function) {
7676
if (target === RatingController) {
77-
return new RatingController(httpSettingsService.instance, httpRatingService.instance);
77+
return new RatingController(settingsService.instance, ratingService.instance);
7878
}
7979
return new (target as any)();
8080
},
@@ -111,27 +111,34 @@ describe("RatingController", () => {
111111
});
112112

113113
it("allows requests from User-role users", async () => {
114-
expect.assertions(1);
114+
expect.assertions(2);
115115

116-
httpRatingService.mocks.createRating.mockResolvedValue({} as any);
116+
ratingService.mocks.createRating.mockResolvedValue({} as any);
117117

118118
const response = await fetch(`http://localhost:${port}/api/ratings/rate`, {
119119
method: "POST",
120120
headers: {
121121
"Content-Type": "application/json",
122122
Authorization: "Bearer user-token",
123123
},
124-
body: JSON.stringify({ data: {} }),
124+
body: JSON.stringify({ data: { rating: 3, project: { id: 1 }, criterion: { id: 2 } } }),
125125
});
126126

127-
// Authorization passed; any status other than 403 is acceptable here
128-
expect(response.status).not.toBe(403);
127+
expect(response.status).toBe(200);
128+
expect(ratingService.mocks.createRating).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
project: expect.objectContaining({ id: 1 }),
131+
user: expect.objectContaining({ id: regularUser.id }),
132+
criterion: expect.objectContaining({ id: 2 }),
133+
}),
134+
regularUser,
135+
);
129136
});
130137

131138
it("passes authorization for admin (Root) users", async () => {
132139
expect.assertions(1);
133140

134-
httpRatingService.mocks.createRating.mockResolvedValue({} as any);
141+
ratingService.mocks.createRating.mockResolvedValue({} as any);
135142

136143
const response = await fetch(`http://localhost:${port}/api/ratings/rate`, {
137144
method: "POST",

backend/test/services/rating-service.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,28 @@ describe("RatingService", () => {
175175
BadRequestError,
176176
);
177177
});
178+
179+
it("is forbidden to impersonate other users", async () => {
180+
expect.assertions(1);
181+
182+
settingsService.mocks.getSettings.mockResolvedValue(
183+
{ application: { allowRatingProjects: true } } as any
184+
);
185+
186+
mockProjectsRepo.findOneBy.mockResolvedValue(mockProject);
187+
mockTeamsRepo.findOneBy.mockResolvedValue(mockTeam);
188+
mockRatingsRepo.findOne.mockResolvedValue(null);
189+
190+
mockRating.user = {
191+
...mockUser,
192+
id: 1234
193+
};
194+
195+
await expect(ratingService.createRating(mockRating, mockUser)).rejects.toThrow(
196+
ForbiddenError,
197+
);
198+
});
199+
178200
});
179201
});
180202

backup/admit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@
558558
we're happy to let you
559559
know that you'll be
560560
going to be part of
561-
<b>Hackaburg 2025! </b>
561+
<b>Hackaburg 2026! </b>
562562
</p>
563563
<p style="margin: 0"> </p>
564564
<p style="margin: 0">

frontend/src/components/base/dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const SimpleDialog = (props: SimpleDialogProps) => {
4141
<DialogTitle>Invite a friend to join</DialogTitle>
4242
<DialogContent>
4343
<DialogContentText id="alert-dialog-description">
44-
Please feel free to invite a friend to join Hackaburg 2025. You can
44+
Please feel free to invite a friend to join Hackaburg 2026. You can
4545
use the following link to invite them.
4646
<div>
4747
<TextField
@@ -71,7 +71,7 @@ export const SimpleDialog = (props: SimpleDialogProps) => {
7171
WhatsApp
7272
</MuiButton>
7373
<MuiButton
74-
href="mailto:?subject=Hackaburg 2025&body=I am visiting Hackaburg this year! Join me at https://hackaburg.de"
74+
href="mailto:?subject=Hackaburg 2026&body=I am visiting Hackaburg this year! Join me at https://hackaburg.de"
7575
variant="outlined"
7676
startIcon={<MdOutlineMail />}
7777
style={{
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React, { useState } from "react";
2+
import {
3+
Stack,
4+
FormControl,
5+
RadioGroup,
6+
FormControlLabel,
7+
Radio,
8+
Button,
9+
Alert,
10+
Typography,
11+
Tooltip,
12+
} from "@mui/material";
13+
import { api } from "../../hooks/use-api";
14+
import { useLoginContext } from "../../contexts/login-context";
15+
16+
export const CriterionRating = ({
17+
criterion,
18+
project,
19+
}) => {
20+
const loginState = useLoginContext();
21+
const { user } = loginState;
22+
23+
const [rating, setRating] = useState<string>("3");
24+
const [isSubmitting, setIsSubmitting] = useState(false);
25+
const [error, setError] = useState<string | null>(null);
26+
27+
const handleSubmit = async () => {
28+
setIsSubmitting(true);
29+
setError(null);
30+
31+
console.log({ criterion, user, project })
32+
33+
await api.createRating({
34+
criterion: {
35+
Id: criterion.id
36+
},
37+
rating: parseInt(rating),
38+
user: {
39+
id: user.id
40+
},
41+
project: {
42+
id: project.id
43+
},
44+
});
45+
onRatingSubmitted?.();
46+
47+
setIsSubmitting(false);
48+
};
49+
50+
return (
51+
<div
52+
style={{
53+
border: "1px solid grey", "border-radius": "5px",
54+
padding: "10px",
55+
margin: "1rem auto"
56+
}}
57+
>
58+
<Stack
59+
direction={{ xs: "column", sm: "row" }}
60+
spacing={{ xs: 1, sm: 4 }}
61+
alignItems={{ xs: "flex-start", sm: "center" }}
62+
justifyContent="center"
63+
>
64+
<Tooltip title={criterion.description}>
65+
<Typography variant="h6" sx={{ cursor: "help" }}>
66+
{criterion.title}
67+
</Typography>
68+
</Tooltip>
69+
70+
<FormControl component="fieldset">
71+
<RadioGroup
72+
row
73+
value={rating}
74+
onChange={(e) => setRating(e.target.value)}
75+
>
76+
{[1, 2, 3, 4, 5].map((value) => (
77+
<FormControlLabel
78+
key={value}
79+
value={value.toString()}
80+
control={<Radio disabled={isSubmitting} />}
81+
label={value.toString()}
82+
/>
83+
))}
84+
</RadioGroup>
85+
</FormControl>
86+
87+
<Button
88+
variant="contained"
89+
onClick={handleSubmit}
90+
disabled={isSubmitting}
91+
sx={{ alignSelf: { xs: "stretch", sm: "auto" } }}
92+
>
93+
{isSubmitting ? "Submitting..." : "Submit"}
94+
</Button>
95+
</Stack>
96+
97+
{error && <Alert severity="error">{error}</Alert>}
98+
</div>
99+
);
100+
};

frontend/src/components/pages/read-only-project.tsx

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { Page } from "./page";
66
import { Button } from "../base/button";
77
import { RoundedImage } from "../base/image";
88
import { Divider } from "../base/divider";
9-
import { useApi } from "../../hooks/use-api";
9+
import { useApi, api } from "../../hooks/use-api";
1010
import { useLoginContext } from "../../contexts/login-context";
1111
import { TeamDTO } from "../../api/types/dto";
1212
import { PageHeader } from "../base/page-header";
13+
import { CriterionRating } from "./criterion";
1314

1415
const HeaderContainer = styled(NonGrowingFlexContainer)`
1516
justify-content: space-between;
@@ -29,6 +30,31 @@ export const ReadOnlyProject = ({ project }) => {
2930
const [description, setDescription] = React.useState("");
3031
const [image, setImage] = React.useState("");
3132
const [allowRating, setAllowRating] = React.useState(false);
33+
const [criteria, setCriteria] = React.useState([]);
34+
const [allUsers, setAllUsers] = React.useState([]);
35+
36+
React.useEffect(() => {
37+
if (project) {
38+
setId(project.id);
39+
setTitle(project.title);
40+
setDescription(project.description);
41+
setImage(project.image);
42+
setAllowRating(project.allowRating);
43+
}
44+
}, [project]);
45+
46+
React.useEffect(
47+
() => {
48+
api.getAllUsers().then((allUsers) => {
49+
setAllUsers(allUsers)
50+
});
51+
52+
api.getAllCriteria().then((criteria) => {
53+
setCriteria(criteria)
54+
});
55+
},
56+
[]
57+
);
3258

3359
const {
3460
value: didUpdateProject,
@@ -54,28 +80,13 @@ export const ReadOnlyProject = ({ project }) => {
5480
[id, title, description, image, allowRating],
5581
);
5682

57-
const { value: allUsers } = useApi(
58-
async (apiClient) => apiClient.getAllUsers(),
59-
[],
60-
);
61-
6283
const handleSubmit = React.useCallback((event: React.SyntheticEvent) => {
6384
event.preventDefault();
6485
}, []);
6586

6687
const updateProjectDone =
6788
Boolean(didUpdateProject) && !updateProjectInProgress && !updateProjectError;
6889

69-
React.useEffect(() => {
70-
if (project) {
71-
setId(project.id);
72-
setTitle(project.title);
73-
setDescription(project.description);
74-
setImage(project.image);
75-
setAllowRating(project.allowRating);
76-
}
77-
}, [project]);
78-
7990
return (
8091
<Page>
8192
<PageHeader pageTitle={project?.title}/>
@@ -90,6 +101,15 @@ export const ReadOnlyProject = ({ project }) => {
90101
<p>{project?.description}</p>
91102
</FlexRowContainer>
92103
</div>
104+
<div>
105+
<h2 style={{ "margin-top": "4rem" }}>Rate this Project</h2>
106+
Hover the criterion for more information.
107+
Rate criteria high, if you think the project did well in this regard.
108+
{criteria.map(criterion => <CriterionRating
109+
criterion={criterion}
110+
project={project}
111+
/>)}
112+
</div>
93113
</Page>
94114
);
95115
};

frontend/src/components/pages/status.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const Status = () => {
9898
<InternalLink to={Routes.ProfileForm}>
9999
profile form
100100
</InternalLink>
101-
, any time between <b>01.03.205 - 31.04.2025</b>
101+
, any time between <b>01.03.2026 - 31.04.2026</b>
102102
</Text>
103103
</>
104104
)}
@@ -145,14 +145,14 @@ export const Status = () => {
145145
<>
146146
<Text style={{ fontSize: "1.15rem" }}>
147147
We will come back to you and send you a acceptance mail until{" "}
148-
<b>01.05.2025</b>.
148+
<b>01.05.2026</b>.
149149
</Text>
150150
</>
151151
)}
152152
{user?.confirmed && (
153153
<>
154154
<Text style={{ fontSize: "1.15rem" }}>
155-
Congratulations! You got accepted for Hackaburg 2025. 🎉
155+
Congratulations! You got accepted for Hackaburg 2026. 🎉
156156
</Text>
157157
</>
158158
)}
@@ -173,7 +173,7 @@ export const Status = () => {
173173
<>
174174
<Text style={{ fontSize: "1.15rem" }}>
175175
If you got accepted, you need to confirm your spot until{" "}
176-
<b>08.05.2025</b>
176+
<b>08.05.2026</b>
177177
{user?.admitted && (
178178
<>
179179
{" "}

frontend/src/components/routers/sidebar/sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const Sidebar = () => {
106106
<H1 style={{ color: "white" }}>HACKABURG</H1>
107107
<H2 style={{ color: "white" }}>CONTROL CENTER</H2>
108108
<p style={{ color: "white" }}>
109-
All important information about <br></br>the <b>Hackaburg 2025</b>{" "}
109+
All important information about <br></br>the <b>Hackaburg 2026</b>{" "}
110110
event
111111
</p>
112112
</div>

0 commit comments

Comments
 (0)