Plane: Cross-Tenant Stored XSS in Intake Enables Account Takeover

Any account on the instance plants HTML in foreign intake queues without holding a single project membership and permanently takes over the account of whoever clicks.

Advisory ID: TP-2026-079
Product: Plane (open-source project management for issues, cycles and modules)
Vulnerability type: Cross-tenant stored cross-site scripting with missing authorization (CWE-79, CWE-862)
CVE: CVE-2026-104979
CVSS 3.1: 8.7 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Affected versions: <= 1.3.1
Vendor advisory: GHSA-hh2r-3hwp-mvq3
Fixed in: 1.4.0
Reported: 16 May 2026

Summary

Plane is an open-source project management application in which several workspaces operate side by side on the same instance. The public intake API, through which outsiders submit feedback on a published project, stores the submitted HTML without sanitization and requires nothing but a signed-in account, not a membership in the target project. A freshly registered user without any affiliation can therefore place a prepared link in the intake queue of a foreign workspace, where project members review it as part of their normal work. A parser flaw in the editor link extension makes that link run as JavaScript in the viewer's session on click, where it creates a long-lived API token and exfiltrates it. The token outlives logout and password changes, which turns a single click into a permanent account takeover.

Root cause

IntakeIssuePublicViewSet.create writes the description_html field straight to the database through Issue.objects.create, bypassing the sanitization that the regular serializer performs via validate_html_content (apps/api/plane/space/views/intake.py:145-152). The endpoint inherits the default IsAuthenticated permission with no workspace or project membership check, so any account on the instance writes into any project with intake enabled. The editor link extension screens dangerous protocols with href.toLowerCase().startsWith(...) and never strips leading control characters, while the browser's WHATWG parser does strip them, so a tab-prefixed javascript: passes both checks and reaches the DOM as an executable scheme (packages/editor/src/core/extensions/custom-link/extension.tsx:156-180). The matching click handler calls window.open(href, target) with the attacker-supplied target, and _self sidesteps the browser block on javascript: in new tabs (packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts:43). The instance serves no Content-Security-Policy header on any route, and POST /api/users/api-tokens/ returns a cleartext token with no old-password check, no session binding and no default expiry, which lets the injected code anchor the access permanently.

Proof of Concept

POST /api/public/anchor/<anchor>/intakes/<intake_id>/intake-issues/
Cookie: <session of a freshly registered account with no membership>

{"issue": {"name": "Feedback",
           "description_html": "<p><a target=\"_self\" href=\"\tjavascript:...\">attachment.pdf</a></p>"}}

-> HTTP 200, the HTML is stored verbatim
-> a project member opens the intake view and clicks the link
-> window.open("\tjavascript:...", "_self") runs the code in the victim's session

The tab character before javascript: survives the editor's protocol check but is removed by the browser URL parser. The executed code creates a long-lived API token through the token API and sends it to a server the attacker controls.

Impact

  • Permanent takeover of every account whose holder clicks the injected link in the intake queue.
  • Access through a stolen API token that survives logout, password changes and session rotation.
  • Cross-tenant reach: any account on the instance reaches any project with intake enabled, without holding a membership.
  • Reading and modification of all data in the victim's workspaces with the victim's privileges.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.