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:<payload>", 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).
novuhq/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.
packages/js/src/ui/context/InboxContext.tsx:108: window.open(url, target ?? DEFAULT_TARGET, DEFAULT_REFERRER) 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.
novuhq/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.
javascript: URL and target=_self:POST /v1/workflows Authorization: ApiKey <key>
{"name":"poc","notificationGroupId":"<ng>","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"}}}}]}
POST /v1/inbox/session {"applicationIdentifier":"<appId>","subscriberId":"<sub>"}
GET /v1/inbox/notifications Authorization: Bearer <subscriber-jwt>
-> redirect: {"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}
@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://<dashboard>: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.
localStorage['self-hosted-jwt'] yields takeover of another member or admin account.Jan Kahmen, turingpoint (jan@turingpoint.de)
{
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-22T20:34:35Z",
"nvd_published_at": "2026-09-22T16:17:53Z",
"severity": "MODERATE"
}