CalDAV connection test · Easyappointments: Easy!Appointments has server-side request forgery
Affects alextselegidis/easyappointmentsSource GCVE Database
Not known to be exploited · CVSS 2.7 Low : upgrade to 1.6.1 when convenient.
Data as of 10 Nov 2026, 20:05 UTC
- Patch
- Available – fixed in 1.6.1
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:NExploitation evidence
Disclosed: evidence found.
CVE published 14 Jul 2026
Detectable: no evidence on record.
No public nuclei template indexed
Weaponized: no evidence on record.
No public exploit code indexed in Metasploit or Exploit-DB
Exploited in the wildExploited: no evidence on record.
Not in CISA KEV and no source reports exploitation
Observed by OffSeq MirageSeen by Mirage: no evidence on record.
No attack traffic recorded by OffSeq Mirage sensors
A missing step is not evidence of safety. Sources: cve.org (opens in a new tab), the Metasploit, Exploit-DB and nuclei-templates index as of 10 Nov 2026.
What to do
Upgrade alextselegidis/easyappointments to 1.6.1.
Affected software
| Product | Affected | Fixed in |
|---|---|---|
| Packagistalextselegidis/easyappointments | Affected
| Fixed in
|
Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.
Description
### Summary `Caldav::connect_to_server` at `application/controllers/Caldav.php:60` hands the request's `caldav_url` to a Guzzle `REPORT` call without scheme or host validation. A logged-in backend user (admin, provider, or secretary) reaches loopback, RFC1918, and link-local hosts on the deployment's network. The Guzzle exception path returns the upstream status code plus ~120 bytes of response body in the JSON `message` field (`Caldav.php:74-78`), so the SSRF is semi-blind. ### Preconditions - Backend login on the target instance. Non-admin attackers supply their own `provider_id` and pass the per-row check at `Caldav.php:52`; admins can target any row. - Default deployment per the project's own `docker-compose.yml`, which puts `mysql`, `mailpit`, `phpmyadmin`, `baikal`, `openldap`, `phpldapadmin`, and `swagger-ui` on the same docker network as `php-fpm`. ### Details ```php // application/controllers/Caldav.php:45-82 public function connect_to_server(): void { try { $provider_id = request('provider_id'); $user_id = session('user_id'); if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) { throw new RuntimeException('You do not have the required permissions for this task.'); } $caldav_url = request('caldav_url'); // (*) attacker-controlled $caldav_username = request('caldav_username'); $caldav_password = request('caldav_password'); $this->caldav_sync->test_connection($caldav_url, $caldav_username, $caldav_password); // (*) sink ... } catch (GuzzleException | InvalidArgumentException $e) { json_response([ 'success' => false, 'message' => $e->getMessage(), // (*) upstream body reflected ]); } } ``` The per-row check at line 52 only constrains *which provider record* the caller may write to; it does not constrain *where the outbound request lands*. `$caldav_url` flows unchanged into `Caldav_sync::test_connection` at `application/libraries/Caldav_sync.php:389`, which calls `get_http_client` to construct a Guzzle client whose `base_uri` is the attacker URL (`Caldav_sync.php:375-382`), then issues `REPORT` against it via `fetch_events` at `Caldav_sync.php:558`. The only input check in `get_http_client` is `filter_var($caldav_url, FILTER_VALIDATE_URL)` at line 363, which validates the URL grammar - not the host - so loopback, RFC1918, link-local, and arbitrary internal hostnames pass. When Guzzle raises `RequestException`, its `getMessage()` formats as ``Client error: `REPORT http://target/` resulted in a `405 Method Not Allowed` response: <body truncated to ~120 chars>``. `Caldav::connect_to_server` returns that string verbatim in `message`. For `ConnectException` (port closed, DNS failure, TLS handshake error) the message names the host, port, and underlying cURL error number - enough to port-scan the deployment's network. ### 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` (`application/libraries/Instance.php:99`): ```bash docker compose exec -T php-fpm php index.php console install ``` 3. Log in as `administrator` (the project's session cookie is `ea_session`) and create an attacker provider. The default `require_phone_number=1` setting makes `phone_number` mandatory: ```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 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
References
- github.com/alextselegidis/easyappointments/security/advisories/GHSA-pm5p-7w5h-jm5q (opens in a new tab)
- nvd.nist.gov/vuln/detail/CVE-2026-52840 (opens in a new tab)
- github.com/alextselegidis/easyappointments/commit/2da2baed18ec32ad7916e507815709c8f010d510 (opens in a new tab)
- github.com/alextselegidis/easyappointments/commit/4abb10545d83ac1a57d03f6502376ee67696ea7c (opens in a new tab)
- github.com/alextselegidis/easyappointments/commit/6b34b78c47790dfd1dec27cf62926db51be276e9 (opens in a new tab)
- github.com/alextselegidis/easyappointments/commit/6eb9336a91cfb276379506625e81f5bd9ed3a536 (opens in a new tab)
- github.com/alextselegidis/easyappointments (opens in a new tab)
- github.com/alextselegidis/easyappointments/releases/tag/1.6.0 (opens in a new tab)