~ / search / GHSA-8gr3-5j6f-25gp

GHSA-8gr3-5j6f-25gp

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

Source: GitHub Advisory Database (reviewed) Moderate CVE-2026-75510

Packages

EcosystemPackageAffectedFixed in
npm @novu/js < 3.18.0 3.18.0

Details

As written in the advisory (Markdown source, shown as plain text).

## 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) ([email protected])

References

External links from the advisory. They lead to third-party sites.