GHSA-8gr3-5j6f-25gp
Novu: Stored XSS in In-App Inbox via notification redirect.url javascript: scheme
Quick fix
GHSA-8gr3-5j6f-25gp — @novu/js: upgrade to the fixed version with the command below.
npm install @novu/js@3.18.0Details
## 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.
1. Create a v1 in-app workflow with a redirect CTA carrying a `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"}}}}]} ``` 2. 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"} ``` 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://<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](https://turingpoint.de) (jan@turingpoint.de)
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/novuhq/novu/security/advisories/GHSA-8gr3-5j6f-25gp[WEB]
- https://nvd.nist.gov/vuln/detail/CVE-2026-75510[ADVISORY]
- https://github.com/novuhq/novu/pull/11453[WEB]
- https://github.com/novuhq/novu/commit/f105f3d41a4405a75f803634d49f15a967524d8a[WEB]
- https://github.com/novuhq/novu[PACKAGE]
- https://github.com/novuhq/novu/releases/tag/v3.18.0[WEB]