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).
// affectednovuhq/novu self-hosted and cloud, API <= v3.15.0; @novu/js <= 3.15.0 and @novu/react (Inbox component). Confirmed live-exploitable on v3.15.0 (Docker community compose, default config, default roles). Condition: an in-app (Inbox) channel is in use, the standard product configuration. Recipient must use a Chromium-based browser (Chrome, Edge); the javascript: execution does not occur where the browser blocks javascript: in window.open.
// root causepackages/js/src/ui/context/InboxContext.tsx:108: window.open(url, target ?? DEFAULTTARGET, DEFAULTREFERRER) is reached for any URL not starting with /, with no scheme allowlist, so javascript: is passed through. packages/js/src/ui/components/Notification/DefaultNotification.tsx:103: the notification click handler calls navigate(redirect.url, redirect.target), feeding the stored values into the sink. apps/api/src/app/inbox/utils/notification-mapper.ts:85-89: maps cta.data.url and cta.data.target into redirect with no validation of either field. libs/dal/src/repositories/message-template/message-template.schema.ts:41: data: Schema.Types.Mixed accepts the arbitrary target key (not present in the typed interface). libs/application-generic/src/usecases/compile-in-app-template/compile-in-app-template.usecase.ts:35-36: the v1 render path handlebars-compiles cta.data.url and performs no scheme check. libs/application-generic/src/schemas/control/in-app-control.schema.ts:24-27: the v2 control schema enforces url: z.string().regex(redirectUrlRegex) which rejects javascript:, the guard absent from the v1 path and the renderer.
// reproductionnovuhq/novu v3.15.0 Docker community compose, default config. Attacker holds an environment API key (or a member session); victim opens the inbox in Chromium.
- Create a v1 in-app workflow with a redirect CTA carrying a javascript: URL and target=self:
POST /v1/workflows Authorization: ApiKey
{"name":"poc","notificationGroupId":"","active":true,
"steps":[{"template":{"type":"in_app","content":"click me",
"cta":{"type":"redirect","data":{
"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}}}}]}- Trigger it to a subscriber, then read the feed with a subscriber token minted from the public application identifier (no secret):
POST /v1/inbox/session {"applicationIdentifier":"","subscriberId":""}
GET /v1/inbox/notifications Authorization: Bearer
-> redirect: {"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}- 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.
Jan Kahmen, turingpoint (jan@turingpoint.de)