Novu: Stored XSS in In-App Inbox via notification redirect.url javascript: schemeGHSA-8gr3-5j6f-25gp
Fix: novuhq/novu#11453GHSA-8gr3-5j6f-25gp is a Cross-site Scripting (XSS) vulnerability in @novu/js. A fix is available for @novu/js — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-8gr3-5j6f-25gp.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
@novu/jsnpmDescription
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:<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).
Affected
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.
Root cause
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.
Reproduction
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.
- Create a v1 in-app workflow with a redirect CTA carrying a
javascript:URL andtarget=_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"}}}}]}
- 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":"<appId>","subscriberId":"<sub>"}
GET /v1/inbox/notifications Authorization: Bearer <subscriber-jwt>
-> redirect: {"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}
- The subscriber clicks the notification in the
@novu/js/@novu/reactInbox.
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.
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 ([email protected])
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | @novu/js | all versions | 3.18.0npm install @novu/js@3.18.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for @novu/js, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update @novu/js to 3.18.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-8gr3-5j6f-25gp is resolved across your whole dependency graph.
Workarounds
Escape or sanitise the affected output on the server side rather than relying on client-side filtering, and add a Content-Security-Policy that blocks inline script execution so injected markup cannot run even if it reaches the page.
Frequently Asked Questions
Is GHSA-8gr3-5j6f-25gp in your dependencies?
Find it across npm, including transitive dependencies.