CVE-2026-52840
Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network
Easy!Appointments is a self hosted appointment scheduler. In versions prior to 1.6.0, 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. Version 1.6.0 contains a patch.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- High
- User interaction
- None
What an attacker can affect
- Confidentiality
- Low
- Integrity
- None
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
2.7
Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network
Easy!Appointments is a self hosted appointment scheduler. In versions prior to 1.6.0, 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. Version 1.6.0 contains a patch.
- Record
CVE-2026-52840- CVSS 3.1
- 2.7 Low
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
EUVDEUVD-2026-43722
2.7
Easy!Appointments is a self hosted appointment scheduler. In versions prior to 1.6.0, 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. Version 1.6.0 contains a patch.
- Record
EUVD-2026-43722- CVSS 3.1
- 2.7 Low
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-pm5p-7w5h-jm5q
2.7
Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network
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_idand pass the per-row check atCaldav.php:52; admins can target any row. - Default deployment per the project's own
docker-compose.yml, which putsmysql,mailpit,phpmyadmin,baikal,openldap,phpldapadmin, andswagger-uion the same docker network asphp-fpm.
Details
// 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
-
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 -
Run the console installer. The seed sets administrator's password to the literal string
administrator(application/libraries/Instance.php:99):docker compose exec -T php-fpm php index.php console install -
Log in as
administrator(the project's session cookie isea_session) and create an attacker provider. The defaultrequire_phone_number=1setting makesphone_numbermandatory: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)…
- Record
GHSA-pm5p-7w5h-jm5q- CVSS 3.1
- 2.7 Low
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
NVD
2.7
Easy!Appointments is a self hosted appointment scheduler. In versions prior to 1.6.0, 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. Version 1.6.0 contains a patch.
- Record
CVE-2026-52840- CVSS 3.1
- 2.7 Low
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
OSV
2.7
Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network
Easy!Appointments is a self hosted appointment scheduler. In versions prior to 1.6.0, 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. Version 1.6.0 contains a patch.
- Record
CVE-2026-52840- CVSS 3.1
- 2.7 Low
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
OSVGHSA-pm5p-7w5h-jm5q
2.7
Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network
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_idand pass the per-row check atCaldav.php:52; admins can target any row. - Default deployment per the project's own
docker-compose.yml, which putsmysql,mailpit,phpmyadmin,baikal,openldap,phpldapadmin, andswagger-uion the same docker network asphp-fpm.
Details
// 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
-
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 -
Run the console installer. The seed sets administrator's password to the literal string
administrator(application/libraries/Instance.php:99):docker compose exec -T php-fpm php index.php console install -
Log in as
administrator(the project's session cookie isea_session) and create an attacker provider. The defaultrequire_phone_number=1setting makesphone_numbermandatory: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)…
- Record
GHSA-pm5p-7w5h-jm5q- CVSS 3.1
- 2.7 Low
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
alextselegidis/easyappointmentsPackagist |
through 1.5.2 | None known |
alextselegidis/easyappointmentsPackagist |
through 1.5.2, plus listed versions 1.1.01.1.0-beta.11.1.0-beta.21.1.11.2.0 Show 18 moreShow less1.2.0-alpha.11.2.0-beta.11.2.11.3.01.3.0-alpha.11.3.0-beta.11.3.0-beta.21.3.11.3.1-beta.11.3.21.3.2-beta.11.4.01.4.0-beta.11.4.11.4.21.4.2-beta.11.4.31.4.3-beta.1 |
None known |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-52840 is a low vulnerability (CVSS 2.7) in Alextselegidis Easyappointments, published July 14, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.23%, the 14th percentile. No source names a fixed version.
References
github.com/alextselegidis/easyappointments/security/advisories/GHSA-pm5p-7w5h-jm5q
github.com/alextselegidis/easyappointments/commit/6eb9336a91cfb276379506625e81f5bd9ed3a536
nvd.nist.gov/vuln/detail/CVE-2026-52840
github.com/alextselegidis/easyappointments/commit/2da2baed18ec32ad7916e507815709c8f010d510
github.com/alextselegidis/easyappointments/commit/4abb10545d83ac1a57d03f6502376ee67696ea7c
github.com/alextselegidis/easyappointments/commit/6b34b78c47790dfd1dec27cf62926db51be276e9
Left out here: the rest of 2 source descriptions. The JSON record has them.