Skip to content

Commit 5a3f153

Browse files
fix: filter slots by requested date range to prevent date override leakage (calcom#25822)
* fix: filter slots by requested date range to prevent date override leakage Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: move filterSlotsByRequestedDateRange to private method Co-Authored-By: morgan@cal.com <morgan@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e46c1bb commit 5a3f153

1 file changed

Lines changed: 64 additions & 2 deletions

File tree

  • packages/trpc/server/routers/viewer/slots

packages/trpc/server/routers/viewer/slots/util.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,61 @@ export class AvailableSlotsService {
272272
return periodType === PeriodType.ROLLING_WINDOW || periodType === PeriodType.ROLLING;
273273
}
274274

275+
/**
276+
* Filters slots to only include dates within the requested range.
277+
* This is necessary because buildDateRanges uses a ±1 day buffer when checking
278+
* if date overrides should be included (to handle timezone edge cases), which can
279+
* cause slots from adjacent days to leak into the response.
280+
*/
281+
private _filterSlotsByRequestedDateRange<
282+
T extends Record<string, { time: string; attendees?: number; bookingUid?: string }[]>,
283+
>({
284+
slotsMappedToDate,
285+
startTime,
286+
endTime,
287+
timeZone,
288+
}: {
289+
slotsMappedToDate: T;
290+
startTime: string;
291+
endTime: string;
292+
timeZone: string | undefined;
293+
}): T {
294+
if (!timeZone) {
295+
return slotsMappedToDate;
296+
}
297+
const inputStartTime = dayjs(startTime).tz(timeZone);
298+
const inputEndTime = dayjs(endTime).tz(timeZone);
299+
300+
// fr-CA uses YYYY-MM-DD format
301+
const formatter = new Intl.DateTimeFormat("fr-CA", {
302+
year: "numeric",
303+
month: "2-digit",
304+
day: "2-digit",
305+
timeZone: timeZone,
306+
});
307+
308+
const allowedDates = new Set<string>();
309+
for (
310+
let d = inputStartTime.startOf("day");
311+
!d.isAfter(inputEndTime, "day");
312+
d = d.add(1, "day")
313+
) {
314+
allowedDates.add(formatter.format(d.toDate()));
315+
}
316+
317+
const filtered = {} as T;
318+
for (const [date, slots] of Object.entries(slotsMappedToDate)) {
319+
if (allowedDates.has(date)) {
320+
(filtered as Record<string, typeof slots>)[date] = slots;
321+
}
322+
}
323+
return filtered;
324+
}
325+
private filterSlotsByRequestedDateRange = withReporting(
326+
this._filterSlotsByRequestedDateRange.bind(this),
327+
"filterSlotsByRequestedDateRange"
328+
);
329+
275330
private _getAllDatesWithBookabilityStatus(availableDates: string[]) {
276331
const availableDatesSet = new Set(availableDates);
277332
const firstDate = dayjs(availableDates[0]);
@@ -1431,8 +1486,15 @@ export class AvailableSlotsService {
14311486
);
14321487
const withinBoundsSlotsMappedToDate = mapWithinBoundsSlotsToDate();
14331488

1489+
const filteredSlotsMappedToDate = this.filterSlotsByRequestedDateRange({
1490+
slotsMappedToDate: withinBoundsSlotsMappedToDate,
1491+
startTime: input.startTime,
1492+
endTime: input.endTime,
1493+
timeZone: input.timeZone,
1494+
});
1495+
14341496
// We only want to run this on single targeted events and not dynamic
1435-
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
1497+
if (!Object.keys(filteredSlotsMappedToDate).length && input.usernameList?.length === 1) {
14361498
try {
14371499
await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({
14381500
eventDetails: {
@@ -1474,7 +1536,7 @@ export class AvailableSlotsService {
14741536
: null;
14751537

14761538
return {
1477-
slots: withinBoundsSlotsMappedToDate,
1539+
slots: filteredSlotsMappedToDate,
14781540
...troubleshooterData,
14791541
};
14801542
}

0 commit comments

Comments
 (0)