OpenReception is an open-source appointment booking platform aimed at medical practices, with a self-hosted edition and a commercially hosted multi-tenant version. Its 1.0 release was covered as an open-source alternative to commercial schedulers like Doctolib by heise and Golem. Its selling point is end-to-end encryption: patient data is supposed to stay readable only to the staff it is meant for, while the server holds ciphertext it cannot decrypt. A single instance can host many independent practices, and it ships as a SvelteKit application backed by PostgreSQL.

I audited the released code shortly after the 1.0 line shipped and ran a local instance from the official docker-compose to validate findings. Over the audit I reported 16 vulnerabilities. All of them were assigned CVEs, four were rated Critical, and all were fixed before public disclosure on 2026-05-20.

Two of the four hand an attacker full administrative control, one lets them log in as anyone, and the last quietly defeats the encryption the whole product is built on.

Tenant admin self-promotion to GLOBAL_ADMIN (CVE-2026-48086)

OpenReception’s two administrator tiers map onto its multi-tenancy: a TENANT_ADMIN runs a single practice, while a GLOBAL_ADMIN runs the whole platform and can create tenants, list every practice, and manage other administrators. A tenant admin edits their own staff through PUT /api/tenants/{tenantId}/staff/{staffId}, and a staff record carries a role.

The handler authorizes the caller with checkPermission(locals, tenantId, true), which confirms they are an admin of that tenant, and then writes whatever role the request body contains. The body is validated against a Zod schema, and that schema’s role enum includes GLOBAL_ADMIN. Nothing checks that the requested role is not above the caller’s own, so the schema validation ends up being the entire authorization decision, and it accepts GLOBAL_ADMIN.

A tenant admin promotes themselves with one request:

PUT /api/tenants/02fa8204-.../staff/<own-user-id> HTTP/2
Host: clinica.localtest.me
Cookie: access_token=<TENANT_ADMIN session>

{"role":"GLOBAL_ADMIN"}

The response is 200 with the new role. The session token they are holding was issued before the change and still says TENANT_ADMIN, so they log in again and the fresh token carries GLOBAL_ADMIN. An admin-only endpoint that returned 401 a moment earlier now returns 201, and they can create tenants and read every practice on the instance. The same handler accepts any staffId in the same tenant, so an attacker can promote a second account instead of their own and leave their own role history clean.

Unauthenticated GLOBAL_ADMIN creation after setup (CVE-2026-48085)

When an instance is first deployed, the operator claims it by creating the initial GLOBAL_ADMIN through the setup page at /setup/create-admin-account. A guard is meant to retire that page once an administrator exists.

The guard lives in the setup layout’s load function, and a load function runs only on a GET. The form action that handles the POST, the one that actually creates the account, never rechecks whether an admin already exists. So on a fully claimed and configured instance, an unauthenticated POST to the setup action creates another GLOBAL_ADMIN. The account is written with is_active = true and confirmation_state = ACCESS_GRANTED, and the row is committed before the confirmation email is sent, so it exists even if the SMTP step is slow or the connection drops.

SvelteKit’s built-in same-origin check rejects a blind browser CSRF, but any client that sets a matching Origin header sends the request:

POST /setup/create-admin-account HTTP/2
Host: admin.localtest.me
Content-Type: application/x-www-form-urlencoded
Origin: https://admin.localtest.me

type=passphrase&email=attacker@evil.test&language=en&passphrase=<long passphrase>

A login with that email then returns a session whose role is GLOBAL_ADMIN, and GET /api/tenants returns the full list. The fix adds an adminExists() check inside the action, not just the layout.

WebAuthn passkey injection (CVE-2026-48087)

Passkey registration goes through POST /api/auth/register/{userId}, and the flow leans on two cookies set during the preceding challenge step: the WebAuthn challenge itself and a registration email.

The registration handler checks two things: that body.email equals the registration-email cookie, and that the WebAuthn response validates against the challenge cookie. It never checks that the userId in the URL belongs to that email. The credential is then written to whatever user the URL names:

// register/[id]/+server.ts, simplified
// body.email === cookie email            -> checked
// WebAuthn response matches the challenge -> checked
await UserService.addPasskey(params.id, credential);  // params.id is never tied to the email

Because those email checks only ever compare the attacker’s own values, the attacker drives the whole flow with their own email and authenticator:

  1. Call POST /api/auth/challenge with the attacker’s own email. The server sets the challenge and registration-email cookies for that email.
  2. Generate a WebAuthn credential with the attacker’s own authenticator over that challenge.
  3. Submit it to POST /api/auth/register/{VICTIM_USER_ID} with email = attacker@evil.test. Cookie email and body email match, the challenge matches, the checks pass.
  4. The handler writes the attacker’s credential against the victim’s user row.

At login the attacker enters the victim’s email, the assertion from the attacker’s authenticator resolves to the victim’s account, and a session is issued as the victim. Since the attacker never touches the victim’s own email, it works whether or not the victim already has a passkey. The missing step is a server-side lookup:

const targetUser = await UserService.getUserById(params.id);
if (targetUser.email !== body.email) return error(403);

The precondition is knowing the victim’s email and userId, and neither is a real secret: staff userIds are returned by the staff key directory that the public booking flow reads (the same directory as in the next finding). A GLOBAL_ADMIN taken over this way also leaves no new admin row behind. Only user_passkey grows by one, so an operator auditing the user list sees nothing unusual.

Staff crypto poisoning that defeats the encryption (CVE-2026-48088)

This is the finding that reaches the product’s headline feature. A patient’s booking is encrypted so that only the practice’s staff can read it, and to do that the patient needs the staff members’ public keys. Those keys live in a per-tenant directory that the booking flow reads.

Keys are written through POST /api/tenants/{tenantId}/staff/{staffId}/crypto, and that endpoint accepts unauthenticated requests. The handler decides whether the caller is legitimate, logs a warning if not, and then stores the key regardless:

const isAuthenticated = locals.user && locals.user.id === staffId;
const registrationEmail = cookies.get("webauthn-registration-email");
const isRegistration = registrationEmail === email;

if (!isAuthenticated && !isRegistration) {
  log.warn("Unauthorized crypto key storage attempt", { ... });
  // no return, no throw
}
// ... stores the keypair regardless

There is no uniqueness constraint on the table and the staffId in the URL does not have to match a real user, so an attacker posts their own ML-KEM public key into a tenant’s directory, where it sits next to the real ones. They then run the unauthenticated booking bootstrap (the proof-of-work is sixteen bits, solved in about 200 ms), get a booking token, and read the directory back:

GET /api/tenants/{tenantId}/appointments/staff-public-keys
Authorization: Bearer <booking token>

The response contains the attacker’s key alongside the legitimate ones. A patient client encapsulates the tunnel key to every key in that list, so the attacker becomes a silent co-recipient and decapsulates it with their own secret. No ML-KEM or AES-GCM is broken; the recipient list itself was writable by anyone.

Two details make it worse. Omitting the email field turns registrationEmail === email into undefined === undefined, which is true, so the handler treats the request as a legitimate registration and skips the warning entirely, removing the one log line an operator might alert on. And the key fields are barely validated (the literal string <placeholder-base64> was accepted), so flooding the directory with malformed entries breaks the patient’s encryption step and denies booking outright.

The common thread across the four is authorization on state-changing requests, not the cryptography. ML-KEM and AES-GCM are never the weak point; the gap is consistently in which caller is allowed to reach which endpoint.

The remaining findings

A handful are unauthenticated endpoints that trust a known identifier instead of the caller. add-to-tunnel (CVE-2026-48075) inserts a CONFIRMED appointment into a client tunnel after checking only that some tunnel exists for the supplied emailHash, never that the emailHash and tunnelId belong together, so anyone who learns a patient’s tunnelId can write phantom bookings into it. A GET on an appointment by id (CVE-2026-48077) returns the full record with no auth check, while the DELETE in the same file checks permissions. The schedule endpoint (CVE-2026-48078) returns every channel to anonymous callers, including ones marked non-public, with their slots and assigned agents, and the booking bootstrap (CVE-2026-48076) then lets an unauthenticated caller book onto those non-public channels because the booking token is never tied to a channel.

Two more come from central tables read without a tenant filter. The PIN login throttle (CVE-2026-48071) keys its lockout on emailHash alone in a table shared by all tenants, so failed attempts against one practice lock the same patient out of another. Staff deletion (CVE-2026-48074) cleans up pending invites by email with no tenant filter, so deleting a staff member in one practice can remove an unrelated invite in another.

Separately, the tenant detail endpoint (CVE-2026-48080) returned the full tenant row to its own tenant admin, including databaseUrl, the live PostgreSQL connection string, which in the official deployment carries the superuser password shared across every tenant database.

The last few are standard web issues: passphrase login had no rate limiting while the WebAuthn path is throttled (CVE-2026-48084); a javascript: URL stored in a tenant’s link settings rendered unfiltered into the patient-facing footer (CVE-2026-48081); logout dropped the local cookie before the server-side revocation ran, leaving the token valid until expiry (CVE-2026-48079); an unauthenticated log endpoint accepted arbitrary content with newline injection and no limits (CVE-2026-48083); and the booking proof-of-work was fixed at sixteen bits (CVE-2026-48082).

All sixteen findings

CVE Issue Severity
CVE-2026-48086 tenant admin self-promotes to GLOBAL_ADMIN Critical (9.9)
CVE-2026-48087 WebAuthn passkey injection allows account takeover Critical (9.8)
CVE-2026-48085 unauthenticated GLOBAL_ADMIN account creation post-bootstrap Critical (9.8)
CVE-2026-48088 unauthenticated staff crypto poisoning breaks E2E recipient directory Critical (9.4)
CVE-2026-48081 stored click-triggered XSS via javascript: tenant links in the patient-facing footer High (8.1)
CVE-2026-48080 tenant detail endpoint discloses live PostgreSQL connection string High (8.0)
CVE-2026-48084 passphrase login attempts are not rate-limited High (7.4)
CVE-2026-48079 logout clears the local access token before server-side revocation High (7.4)
CVE-2026-48083 unauthenticated POST /api/log accepts arbitrary content with CRLF injection and no size or rate limits Moderate (6.5)
CVE-2026-48076 bootstrap booking flow allows unauthenticated booking on isPublic=false channels Moderate (6.5)
CVE-2026-48075 unauthenticated add-to-tunnel endpoint accepts arbitrary appointment injections Moderate (6.5)
CVE-2026-48071 client PIN challenge throttle is keyed by emailHash only, allowing cross-tenant lockout Moderate (5.8)
CVE-2026-48078 schedule endpoint discloses isPublic=false channels and slot availability to unauthenticated callers Moderate (5.3)
CVE-2026-48077 GET appointment by id returns full appointment record without authorization Moderate (5.3)
CVE-2026-48082 bootstrap challenge proof-of-work difficulty hardcoded to 16 bits Low (3.7)
CVE-2026-48074 staff deletion removes pending invites cross-tenant by email match Low (2.7)

Impact

For a platform built on keeping medical appointment data private, the worst of these reach the data itself. The crypto poisoning, the passkey injection, and the footer XSS each give an attacker a path to plaintext that should be unreadable to anyone outside the practice. The two privilege-escalation paths hand over full administrative control, and on the hosted multi-tenant version that is control over every practice on the instance. The tenant-isolation issues mean practices sharing an instance were not actually separated. Several of the endpoints involved need no authentication at all.

All sixteen were fixed before public disclosure.

I want to thank Karl Ludwig Weise for the quick and professional collaboration throughout the disclosure.

References