Skip to content

Commit 7ba9d38

Browse files
committed
fix: harden BigBlueButton integration
1 parent 010c3df commit 7ba9d38

3 files changed

Lines changed: 121 additions & 21 deletions

File tree

packages/app-store/bigbluebutton/api/add.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { metadata } from "../_metadata";
99
* Installs the BigBlueButton conferencing app for the current user or team.
1010
*/
1111
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
12+
if (req.method !== "POST") {
13+
res.setHeader("Allow", "POST");
14+
res.status(405).json({ message: "Method Not Allowed" });
15+
return;
16+
}
17+
1218
if (!req.session?.user?.id) {
1319
res.status(401).json({ message: "You must be logged in to do this" });
1420
return;
@@ -21,23 +27,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
2127
numericTeamId = Number(teamId);
2228
}
2329

24-
await throwIfNotHaveAdminAccessToTeam({
25-
teamId: numericTeamId,
26-
userId: req.session.user.id,
27-
});
30+
try {
31+
await throwIfNotHaveAdminAccessToTeam({
32+
teamId: numericTeamId,
33+
userId: req.session.user.id,
34+
});
2835

29-
let installForObject: { teamId: number } | { userId: number } = { userId: req.session.user.id };
36+
let installForObject: { teamId: number } | { userId: number } = { userId: req.session.user.id };
3037

31-
if (numericTeamId) {
32-
installForObject = { teamId: numericTeamId };
33-
}
38+
if (numericTeamId) {
39+
installForObject = { teamId: numericTeamId };
40+
}
3441

35-
try {
3642
const alreadyInstalled = await prisma.credential.findFirst({
3743
where: {
3844
type: metadata.type,
3945
...installForObject,
4046
},
47+
select: {
48+
id: true,
49+
},
4150
});
4251

4352
if (alreadyInstalled) {
@@ -51,6 +60,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
5160
...installForObject,
5261
appId: metadata.slug,
5362
},
63+
select: {
64+
id: true,
65+
},
5466
});
5567

5668
if (!installation) {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ describe("BigBlueButton VideoApiAdapter helpers", () => {
99
expect(testHelpers.normalizeServerUrl("https://bbb.example.com/bigbluebutton/api/").toString()).toBe(
1010
"https://bbb.example.com/bigbluebutton/api/"
1111
);
12+
expect(testHelpers.normalizeServerUrl("https://bbb.example.com/bigbluebutton/api").toString()).toBe(
13+
"https://bbb.example.com/bigbluebutton/api/"
14+
);
1215
});
1316

1417
test("creates signed API URLs with checksums", () => {
@@ -26,4 +29,41 @@ describe("BigBlueButton VideoApiAdapter helpers", () => {
2629
"https://bbb.example.com/api/create?name=Demo+Meeting&meetingID=booking-123&checksum=f902c3bc5984e0cb403b17eed581ed99195d15a4"
2730
);
2831
});
32+
33+
test("does not duplicate the API segment when creating signed URLs", () => {
34+
const url = testHelpers.createApiUrl({
35+
callName: "join",
36+
serverUrl: "https://bbb.example.com/api",
37+
sharedSecret: "secret",
38+
params: {
39+
meetingID: "booking-123",
40+
password: "attendee-password",
41+
},
42+
});
43+
44+
expect(url.startsWith("https://bbb.example.com/api/join?")).toBe(true);
45+
expect(url).not.toContain("/api/api/");
46+
});
47+
48+
test("derives stable per-meeting passwords", () => {
49+
const attendeePassword = testHelpers.createMeetingPassword({
50+
meetingID: "booking-123",
51+
role: "attendee",
52+
sharedSecret: "secret",
53+
});
54+
const repeatedAttendeePassword = testHelpers.createMeetingPassword({
55+
meetingID: "booking-123",
56+
role: "attendee",
57+
sharedSecret: "secret",
58+
});
59+
const moderatorPassword = testHelpers.createMeetingPassword({
60+
meetingID: "booking-123",
61+
role: "moderator",
62+
sharedSecret: "secret",
63+
});
64+
65+
expect(attendeePassword).toBe(repeatedAttendeePassword);
66+
expect(attendeePassword).not.toBe(moderatorPassword);
67+
expect(attendeePassword).toMatch(/^[a-f0-9]{32}$/);
68+
});
2969
});

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

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHash, randomUUID } from "node:crypto";
1+
import { createHash, createHmac, randomUUID } from "node:crypto";
22
import { WEBAPP_URL } from "@calcom/lib/constants";
33
import type { CalendarEvent } from "@calcom/types/Calendar";
44
import type { PartialReference } from "@calcom/types/EventManager";
@@ -12,8 +12,7 @@ type BigBlueButtonKeys = {
1212
bigBlueButtonSharedSecret: string;
1313
};
1414

15-
const ATTENDEE_PASSWORD = "attendee";
16-
const MODERATOR_PASSWORD = "moderator";
15+
const BIGBLUEBUTTON_API_TIMEOUT_MS = 10_000;
1716

1817
/**
1918
* Reads and validates the configured BigBlueButton server URL and shared secret.
@@ -26,9 +25,12 @@ const getBigBlueButtonKeys = async (): Promise<BigBlueButtonKeys> =>
2625
*/
2726
const normalizeServerUrl = (serverUrl: string): URL => {
2827
const url = new URL(serverUrl);
28+
const trimmedPathname = url.pathname.replace(/\/+$/, "");
2929

30-
if (!url.pathname.endsWith("/api/")) {
31-
url.pathname = `${url.pathname.replace(/\/$/, "")}/api/`;
30+
if (trimmedPathname.endsWith("/api")) {
31+
url.pathname = `${trimmedPathname}/`;
32+
} else {
33+
url.pathname = `${trimmedPathname}/api/`;
3234
}
3335

3436
return url;
@@ -40,6 +42,19 @@ const normalizeServerUrl = (serverUrl: string): URL => {
4042
const createChecksum = (callName: string, query: string, sharedSecret: string): string =>
4143
createHash("sha1").update(`${callName}${query}${sharedSecret}`).digest("hex");
4244

45+
/**
46+
* Derives stable per-meeting BigBlueButton passwords without storing moderator secrets.
47+
*/
48+
const createMeetingPassword = ({
49+
meetingID,
50+
role,
51+
sharedSecret,
52+
}: {
53+
meetingID: string;
54+
role: "attendee" | "moderator";
55+
sharedSecret: string;
56+
}): string => createHmac("sha256", sharedSecret).update(`${meetingID}:${role}`).digest("hex").slice(0, 32);
57+
4358
/**
4459
* Creates a signed BigBlueButton API URL for the given call and parameters.
4560
*/
@@ -68,8 +83,24 @@ const createApiUrl = ({
6883
* Calls BigBlueButton and treats non-success API responses as failures.
6984
*/
7085
const callBigBlueButtonApi = async (url: string): Promise<void> => {
71-
const response = await fetch(url);
72-
const body = await response.text();
86+
const controller = new AbortController();
87+
const timeout = setTimeout(() => controller.abort(), BIGBLUEBUTTON_API_TIMEOUT_MS);
88+
89+
let response: Response;
90+
let body: string;
91+
92+
try {
93+
response = await fetch(url, { signal: controller.signal });
94+
body = await response.text();
95+
} catch (error) {
96+
if (error instanceof Error && error.name === "AbortError") {
97+
throw new Error("BigBlueButton API request timed out");
98+
}
99+
100+
throw error;
101+
} finally {
102+
clearTimeout(timeout);
103+
}
73104

74105
if (!response.ok || !body.includes("<returncode>SUCCESS</returncode>")) {
75106
throw new Error("BigBlueButton API request failed");
@@ -95,6 +126,16 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
95126
): Promise<VideoCallData> => {
96127
const { bigBlueButtonServerUrl, bigBlueButtonSharedSecret } = await getBigBlueButtonKeys();
97128
const meetingID = getMeetingId(eventData, bookingRef);
129+
const attendeePassword = createMeetingPassword({
130+
meetingID,
131+
role: "attendee",
132+
sharedSecret: bigBlueButtonSharedSecret,
133+
});
134+
const moderatorPassword = createMeetingPassword({
135+
meetingID,
136+
role: "moderator",
137+
sharedSecret: bigBlueButtonSharedSecret,
138+
});
98139

99140
await callBigBlueButtonApi(
100141
createApiUrl({
@@ -104,8 +145,8 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
104145
params: {
105146
name: eventData.title,
106147
meetingID,
107-
attendeePW: ATTENDEE_PASSWORD,
108-
moderatorPW: MODERATOR_PASSWORD,
148+
attendeePW: attendeePassword,
149+
moderatorPW: moderatorPassword,
109150
logoutURL: WEBAPP_URL,
110151
},
111152
})
@@ -114,15 +155,15 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
114155
return {
115156
type: metadata.type,
116157
id: meetingID,
117-
password: ATTENDEE_PASSWORD,
158+
password: attendeePassword,
118159
url: createApiUrl({
119160
callName: "join",
120161
serverUrl: bigBlueButtonServerUrl,
121162
sharedSecret: bigBlueButtonSharedSecret,
122163
params: {
123164
fullName: eventData.attendees[0]?.name || "Guest",
124165
meetingID,
125-
password: ATTENDEE_PASSWORD,
166+
password: attendeePassword,
126167
redirect: "true",
127168
},
128169
}),
@@ -136,6 +177,11 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
136177
createOrUpdateMeeting(eventData, bookingRef),
137178
deleteMeeting: async (meetingID: string): Promise<void> => {
138179
const { bigBlueButtonServerUrl, bigBlueButtonSharedSecret } = await getBigBlueButtonKeys();
180+
const moderatorPassword = createMeetingPassword({
181+
meetingID,
182+
role: "moderator",
183+
sharedSecret: bigBlueButtonSharedSecret,
184+
});
139185

140186
await callBigBlueButtonApi(
141187
createApiUrl({
@@ -144,7 +190,7 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
144190
sharedSecret: bigBlueButtonSharedSecret,
145191
params: {
146192
meetingID,
147-
password: MODERATOR_PASSWORD,
193+
password: moderatorPassword,
148194
},
149195
})
150196
);
@@ -155,10 +201,12 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
155201
export const testHelpers: {
156202
createApiUrl: typeof createApiUrl;
157203
createChecksum: typeof createChecksum;
204+
createMeetingPassword: typeof createMeetingPassword;
158205
normalizeServerUrl: typeof normalizeServerUrl;
159206
} = {
160207
createApiUrl,
161208
createChecksum,
209+
createMeetingPassword,
162210
normalizeServerUrl,
163211
};
164212

0 commit comments

Comments
 (0)