Cal.com’s Access Control Mess: How It Let Attackers Hijack Accounts and Dump Millions of Bookings

Cal.com’s Access Control Mess: How It Let Attackers Hijack Accounts and Dump Millions of Bookings

Cal.com’s Access Control Mess

Researchers just dug into Cal.com Cloud, that open source scheduling tool folks use as a Calendly alternative with calendar syncs, team features, and APIs.

What they found were some nasty, interlinked access control issues that let anyone fully take over user accounts or snoop on every booking, including private meeting details and personal info on attendees. This hit their cloud setup, which ran Prisma on Postgres with Next.js, and exposed millions of records.

Cal.com aims big on connecting people through scheduling. While it’s open source with strong community support, version 6.0.7 had these gaps, which were quickly fixed in 6.0.8. Let’s break down what went wrong technically.

The Big One: Stealing Accounts Through Org Invite Tricks

The scariest part was this three step chain (think CVSS 9.1 critical), where someone running their own organization could snag any other user’s account just by knowing their email during a signup via invite token.

First Slip-Up: Username Check Ignores Org Users

In packages/lib/server/username.ts (lines 239-286), the usernameCheckForSignup function globally retrieves the user by email using prisma.user.findUnique({ where: { email } }), but then it bails on checking availability if they’re in any organization.

const userIsAMemberOfAnOrg = await prisma.membership.findFirst({
  where: {
    userId: user.id,
    team: { isOrganization: true },
  },
});

if (!userIsAMemberOfAnOrg) {
  // Only here does it actually check username stuff
  const isClaimingAlreadySetUsername = user.username === username;
  const isClaimingUnsetUsername = !user.username;
  response.available = isClaimingUnsetUsername || isClaimingAlreadySetUsername;
}
// Org folks? It just defaults to available: true and keeps going

It figures org members don’t need the check, so signup sails through even for existing accounts.

Second Problem: Email Checks Only Your Own Org

Then packages/features/auth/signup/utils/validateUsername.ts (lines 45-68) looks for duplicate emails, but limits it to the attacker’s org:

const existingUser = await prisma.user.findFirst({
  where: {
    ...(organizationId ? { organizationId } : {}),  // Locked to attacker's org
    OR: [
      ...(!organizationId ? [{ username }] : [{}]),
      {
        AND: [
          { email },
          {
            OR: [
              { emailVerified: { not: null } },
              { AND: [{ password: { isNot: null } }, { username: { not: null } }] },
            ],
          },
        ],
      },
    ],
  },
});

That spits out SQL like:

SELECT email FROM User 
WHERE organizationId = <attacker_org_id> 
  AND email = 'victim@example.com' 
  AND (emailVerified IS NOT NULL OR (password IS NOT NULL AND username IS NOT NULL));

Victim in another org? No hit, so it thinks the email’s free. Should’ve checked everywhere.

The Killer: Upsert Clobbers Everything Globally

Finally, packages/features/auth/signup/handlers/calcomHandler.ts (lines 170-182) does the prisma. user.upsert({ where: { email } }):

const user = await prisma.user.upsert({
  where: { email },  // Finds victim anywhere since emails are unique globally
  update: {
    username,  // Gone
    emailVerified: new Date(Date.now()),
    password: {
      upsert: {
        create: { hash: hashedPassword },
        update: { hash: hashedPassword },  // Victim locked out
      },
    },
    organizationId,  // Yanked to attacker's org
  },
  // Won't create new—victim exists
});

Emails are unique across the DB, so it updates the real user: new password, verified status, and org switch. Victim’s toast; attacker’s in.

How the Attack Plays Out

  1. Attacker makes an org invite: https://app.cal.com/signup?token=<64-char-hex>.
  2. Fills out the form with the victim’s email, password, and a username.
  3. Checks pass → upsert takes over.
  4. Logs in, owns calendars, tokens, everything. No alert to the victim.

Fix in v6.0.8: That commit 170203051f adds a straight check if a verified user with that email exists, block it.

Next Issue: Bookings Wide Open Thanks to Next.js Weirdness

Another chain (CVSS 8.2 high) allowed low priv API keys to read/delete any booking or calendar via IDOR and exposed routes.

Direct Access to Internal Handlers

API v1 had files like /api/v1/bookings/_get.ts, meant for internal use after index.ts auth. But Next.js exposes /_get, /_post, etc., directly no middleware.

So with any v1 key:

  • GET /api/v1/bookings/_get?id=123 → All attendee emails/names, meeting info.
  • DELETE /api/v1/bookings/_delete?id=123 → Poof, gone.
  • Calendars too: delete anyone’s integrations.

What Leaked:

  • PII from attendees.
  • Full meeting deets.
  • Everything, cross-org.

Why: Handlers trusted upstream auth; no param binding to caller’s tenant; underscore paths public.

Fix: PR #25554 slaps middleware on those paths to return 403.

What It All Means Technically

Stuff like this is why BAC tops OWASP 2025 it’s everywhere:

  • Multi-tenant scoping fails globally.
  • Defaults sneak through.
  • Prisma upserts bite without complete checks.
  • Next.js gotchas need explicit guards.
  • Chains turn meh bugs critical.

Fix Tips:

  1. Always findUnique verified emails before upsert.
  2. Global + scoped checks.
  3. Block underscore routes in middleware.
  4. Scope API keys by org/user.
  5. Rate signup/invites; alert changes.
VulnerabilityCVSSScopeImpactFix
ATO Chain9.1Cross-OrgFull TakeoverPre-upsert check
Bookings IDOR8.2PlatformData ExposureRoute blocking

Cal.com fixed it quickly, good move. If you’re building scheduling apps, double-check those Prisma ops, Next.js paths, and org logic .(Source)

Site: cybersecuritypath.com

Leave a Comment

Your email address will not be published. Required fields are marked *