Skip to content

Commit b6058b0

Browse files
author
Richard Nevins
committed
fix: harden BigBlueButton app install flow
1 parent 3cd6df2 commit b6058b0

2 files changed

Lines changed: 126 additions & 27 deletions

File tree

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,81 @@
11
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
2+
import { ErrorCode } from "@calcom/lib/errorCodes";
3+
import { ErrorWithCode } from "@calcom/lib/errors";
24
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
35
import prisma from "@calcom/prisma";
46
import type { NextApiRequest, NextApiResponse } from "next";
57
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
68

7-
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
9+
const getSingleQueryValue = (value: string | string[] | undefined) =>
10+
Array.isArray(value) ? value[0] : value;
11+
12+
const getTeamId = (value: string | string[] | undefined) => {
13+
const rawTeamId = getSingleQueryValue(value);
14+
if (!rawTeamId) {
15+
return null;
16+
}
17+
18+
if (!/^\d+$/.test(rawTeamId)) {
19+
throw new ErrorWithCode(ErrorCode.BadRequest, "Invalid teamId");
20+
}
21+
22+
return Number(rawTeamId);
23+
};
24+
25+
const getSafeReturnTo = (value: string | string[] | undefined) => {
26+
const returnTo = getSingleQueryValue(value);
27+
if (
28+
!returnTo ||
29+
!returnTo.startsWith("/") ||
30+
returnTo.startsWith("//") ||
31+
/^[a-z][a-z\d+.-]*:/i.test(returnTo)
32+
) {
33+
return getInstalledAppPath({
34+
variant: "conferencing",
35+
slug: "bigbluebutton",
36+
});
37+
}
38+
39+
return returnTo;
40+
};
41+
42+
export default async function handler(
43+
req: NextApiRequest,
44+
res: NextApiResponse,
45+
) {
846
if (!req.session?.user?.id) {
9-
return res.status(401).json({ message: "You must be logged in to do this" });
47+
return res
48+
.status(401)
49+
.json({ message: "You must be logged in to do this" });
1050
}
1151

1252
const { teamId, returnTo } = req.query;
1353

14-
await throwIfNotHaveAdminAccessToTeam({
15-
teamId: teamId ? Number(teamId) : null,
16-
userId: req.session.user.id,
17-
});
54+
try {
55+
const teamIdNumber = getTeamId(teamId);
56+
57+
await throwIfNotHaveAdminAccessToTeam({
58+
teamId: teamIdNumber,
59+
userId: req.session.user.id,
60+
});
1861

19-
const installForObject = teamId ? { teamId: Number(teamId) } : { userId: req.session.user.id };
20-
const appType = "bigbluebutton_video";
62+
const installForObject = teamIdNumber
63+
? { teamId: teamIdNumber }
64+
: { userId: req.session.user.id };
65+
const appType = "bigbluebutton_video";
2166

22-
try {
2367
const alreadyInstalled = await prisma.credential.findFirst({
2468
where: {
2569
type: appType,
2670
...installForObject,
2771
},
72+
select: {
73+
id: true,
74+
},
2875
});
2976

3077
if (alreadyInstalled) {
31-
throw new Error("Already installed");
78+
throw new ErrorWithCode(ErrorCode.BookingConflict, "Already installed");
3279
}
3380

3481
const installation = await prisma.credential.create({
@@ -38,17 +85,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
3885
...installForObject,
3986
appId: "bigbluebutton",
4087
},
88+
select: {
89+
id: true,
90+
},
4191
});
4292

4393
if (!installation) {
44-
throw new Error("Unable to create user credential for bigbluebutton");
94+
throw new ErrorWithCode(
95+
ErrorCode.InternalServerError,
96+
"Unable to create user credential for bigbluebutton",
97+
);
4598
}
4699
} catch (error: unknown) {
47100
const httpError = getServerErrorFromUnknown(error);
48-
return res.status(httpError.statusCode).json({ message: httpError.message });
101+
return res
102+
.status(httpError.statusCode)
103+
.json({ message: httpError.message });
49104
}
50105

51106
return res.status(200).json({
52-
url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "bigbluebutton" }),
107+
url: getSafeReturnTo(returnTo),
53108
});
54109
}

packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { createHash, randomUUID } from "node:crypto";
22
import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
33
import type { PartialReference } from "@calcom/types/EventManager";
4-
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
4+
import type {
5+
VideoApiAdapter,
6+
VideoCallData,
7+
} from "@calcom/types/VideoApiAdapter";
58
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
69
import { metadata } from "../_metadata";
710

@@ -18,10 +21,22 @@ const normalizeApiUrl = (serverUrl: string) => {
1821
const checksum = (method: string, query: string, sharedSecret: string) =>
1922
createHash("sha1").update(`${method}${query}${sharedSecret}`).digest("hex");
2023

21-
const deriveMeetingPassword = (role: "attendee" | "moderator", meetingID: string, sharedSecret: string) =>
22-
createHash("sha256").update(`${role}:${meetingID}:${sharedSecret}`).digest("hex").slice(0, 24);
23-
24-
const buildApiUrl = (apiUrl: string, method: string, params: URLSearchParams, sharedSecret: string) => {
24+
const deriveMeetingPassword = (
25+
role: "attendee" | "moderator",
26+
meetingID: string,
27+
sharedSecret: string,
28+
) =>
29+
createHash("sha256")
30+
.update(`${role}:${meetingID}:${sharedSecret}`)
31+
.digest("hex")
32+
.slice(0, 24);
33+
34+
const buildApiUrl = (
35+
apiUrl: string,
36+
method: string,
37+
params: URLSearchParams,
38+
sharedSecret: string,
39+
) => {
2540
const query = params.toString();
2641
const signedQuery = new URLSearchParams(params);
2742
signedQuery.set("checksum", checksum(method, query, sharedSecret));
@@ -37,7 +52,9 @@ const assertSuccessResponse = async (response: Response) => {
3752
};
3853

3954
const getBigBlueButtonConfig = async () => {
40-
const appKeys = (await getAppKeysFromSlug(metadata.slug)) as BigBlueButtonKeys;
55+
const appKeys = (await getAppKeysFromSlug(
56+
metadata.slug,
57+
)) as BigBlueButtonKeys;
4158
const serverUrl = appKeys.bigBlueButtonServerUrl?.trim();
4259
const sharedSecret = appKeys.bigBlueButtonSharedSecret?.trim();
4360

@@ -59,8 +76,16 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
5976
createMeeting: async (eventData: CalendarEvent): Promise<VideoCallData> => {
6077
const { apiUrl, sharedSecret } = await getBigBlueButtonConfig();
6178
const meetingID = eventData.uid || randomUUID();
62-
const attendeePassword = deriveMeetingPassword("attendee", meetingID, sharedSecret);
63-
const moderatorPassword = deriveMeetingPassword("moderator", meetingID, sharedSecret);
79+
const attendeePassword = deriveMeetingPassword(
80+
"attendee",
81+
meetingID,
82+
sharedSecret,
83+
);
84+
const moderatorPassword = deriveMeetingPassword(
85+
"moderator",
86+
meetingID,
87+
sharedSecret,
88+
);
6489

6590
const createParams = new URLSearchParams({
6691
name: eventData.title,
@@ -70,7 +95,12 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
7095
record: "false",
7196
});
7297

73-
const createUrl = buildApiUrl(apiUrl, "create", createParams, sharedSecret);
98+
const createUrl = buildApiUrl(
99+
apiUrl,
100+
"create",
101+
createParams,
102+
sharedSecret,
103+
);
74104
await assertSuccessResponse(await fetch(createUrl));
75105

76106
const joinParams = new URLSearchParams({
@@ -89,21 +119,35 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
89119
},
90120
deleteMeeting: async (uid: string): Promise<void> => {
91121
const { apiUrl, sharedSecret } = await getBigBlueButtonConfig();
92-
const moderatorPassword = deriveMeetingPassword("moderator", uid, sharedSecret);
122+
const moderatorPassword = deriveMeetingPassword(
123+
"moderator",
124+
uid,
125+
sharedSecret,
126+
);
93127

94128
const endParams = new URLSearchParams({
95129
meetingID: uid,
96130
password: moderatorPassword,
97131
});
98132

99-
await assertSuccessResponse(await fetch(buildApiUrl(apiUrl, "end", endParams, sharedSecret)));
133+
await assertSuccessResponse(
134+
await fetch(buildApiUrl(apiUrl, "end", endParams, sharedSecret)),
135+
);
100136
},
101137
updateMeeting: (bookingRef: PartialReference): Promise<VideoCallData> => {
138+
const { meetingId, meetingPassword, meetingUrl } = bookingRef;
139+
140+
if (!meetingId || !meetingPassword || !meetingUrl) {
141+
throw new Error(
142+
"BigBlueButton booking reference is missing meeting data",
143+
);
144+
}
145+
102146
return Promise.resolve({
103147
type: metadata.type,
104-
id: bookingRef.meetingId as string,
105-
password: bookingRef.meetingPassword as string,
106-
url: bookingRef.meetingUrl as string,
148+
id: meetingId,
149+
password: meetingPassword,
150+
url: meetingUrl,
107151
});
108152
},
109153
};

0 commit comments

Comments
 (0)