VulnWatch VulnWatch
← Back to dashboard
Medium github · GHSA-8gr3-5j6f-25gp

Novu: Stored XSS in In-App Inbox via notification redirect.url javascript: scheme

Published Sep 22, 2026 CVSS 0.0

Summary

The @novu/js In-App Inbox renderer passes a notification's redirect.url to window.open() with no URL-scheme validation. The value originates from a notification's call-to-action and is delivered to the recipient verbatim.

An authenticated organization member (or any holder of the environment API key) creates a v1 in-app workflow whose step CTA stores cta.data = { url: "javascript:", target: "_self" }. The v1 message-template cta.data field is a Mongoose Mixed type, so the arbitrary target key is accepted and persisted. The server-side inbox mapper copies cta.data.url and cta.data.target into the notification's redirect object with no scheme check. Novu's v2 control schema validates redirect URLs against redirectUrlRegex (which rejects javascript:), proving the intended invariant; the v1 path and the client renderer do not enforce it.

When the recipient clicks the notification, the inbox calls window.open(url, "_self", "noopener noreferrer"). In Chromium browsers a javascript: URL opened with target="_self" executes in the current document origin (the default _blank is browser-blocked, so the attacker sets _self).

Result: a low-privilege content author runs arbitrary JavaScript in the browser of every recipient who clicks, in the origin that hosts the inbox (the customer application or the self-hosted Novu dashboard, neither of which sends a CSP).

Affected

novuhq/novu self-hosted and cloud, API redirect: {"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}

3. The subscriber clicks the notification in the `@novu/js` / `@novu/react` Inbox.

Live-verified: the stored `javascript:` URL is returned verbatim by the inbox feed, and the exact shipped `navigate()` logic invoked from a real click runs `window.open(url,"_self",...)`, executing the payload in the `http://:4000` origin (no CSP); `window.top.__X` was set to the page origin. On the self-hosted dashboard the test inbox bell uses `subscriberId = user.externalId` (apps/dashboard/src/components/inbox-button.tsx:102), so a member targets another member's id and the executing payload reads `localStorage['self-hosted-jwt']` (apps/dashboard/src/utils/self-hosted/jwt-manager.tsx:4), the dashboard session token.

## Impact

- Stored XSS in the recipient's browser, cross-principal (content author to end-user subscriber), persistent, replicated to every recipient of the workflow.
- On the self-hosted dashboard origin (no CSP), theft of `localStorage['self-hosted-jwt']` yields takeover of another member or admin account.
- In a customer application embedding the inbox, session and token theft and authenticated actions in that origin.
- Triggered by the lowest privilege that can author a workflow, default config, one recipient click.

## Credit

Jan Kahmen, [turingpoint](https://turingpoint.de) ([email protected])

Affected AI Products

replicate
Get the weekly digest. Every Monday: top AI security stories of the week. Free.