{"id":"CVE-2026-75510","aliases":["GHSA-8gr3-5j6f-25gp"],"url":"https://o3.security/vulnerability/CVE-2026-75510","summary":"Novu: Stored XSS in In-App Inbox via notification redirect.url javascript: scheme","details":"## Summary\n\nThe `@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.\n\nAn 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.\n\nWhen 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`).\n\nResult: 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).\n\n## Affected\n\nnovuhq/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).\nCondition: an in-app (Inbox) channel is in use, the standard product configuration.\nRecipient must use a Chromium-based browser (Chrome, Edge); the `javascript:` execution does not occur where the browser blocks `javascript:` in `window.open`.\n\n## Root cause\n\npackages/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.\npackages/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.\napps/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.\nlibs/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).\nlibs/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.\nlibs/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.\n\n## Reproduction\n\nnovuhq/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.\n\n1. Create a v1 in-app workflow with a redirect CTA carrying a `javascript:` URL and `target=_self`:\n```\nPOST /v1/workflows   Authorization: ApiKey <key>\n{\"name\":\"poc\",\"notificationGroupId\":\"<ng>\",\"active\":true,\n \"steps\":[{\"template\":{\"type\":\"in_app\",\"content\":\"click me\",\n   \"cta\":{\"type\":\"redirect\",\"data\":{\n     \"url\":\"javascript:window.top.__X=document.domain;void 0\",\"target\":\"_self\"}}}}]}\n```\n2. Trigger it to a subscriber, then read the feed with a subscriber token minted from the public application identifier (no secret):\n```\nPOST /v1/inbox/session {\"applicationIdentifier\":\"<appId>\",\"subscriberId\":\"<sub>\"}\nGET  /v1/inbox/notifications   Authorization: Bearer <subscriber-jwt>\n-> redirect: {\"url\":\"javascript:window.top.__X=document.domain;void 0\",\"target\":\"_self\"}\n```\n3. The subscriber clicks the notification in the `@novu/js` / `@novu/react` Inbox.\n\nLive-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.\n\n## Impact\n\n- Stored XSS in the recipient's browser, cross-principal (content author to end-user subscriber), persistent, replicated to every recipient of the workflow.\n- On the self-hosted dashboard origin (no CSP), theft of `localStorage['self-hosted-jwt']` yields takeover of another member or admin account.\n- In a customer application embedding the inbox, session and token theft and authenticated actions in that origin.\n- Triggered by the lowest privilege that can author a workflow, default config, one recipient click.\n\n## Credit\n\nJan Kahmen, [turingpoint](https://turingpoint.de) (jan@turingpoint.de)","published":"2026-09-22T15:47:19.922Z","modified":"2026-10-21T02:59:23.892071753Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"@novu/js","fixedVersion":"3.18.0"}],"fix":{"url":"https://github.com/novuhq/novu/commit/f105f3d41a4405a75f803634d49f15a967524d8a","label":"novuhq/novu@f105f3d"},"references":[{"type":"WEB","url":"https://github.com/novuhq/novu/releases/tag/v3.18.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/75xxx/CVE-2026-75510.json"},{"type":"ADVISORY","url":"https://github.com/novuhq/novu/security/advisories/GHSA-8gr3-5j6f-25gp"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-75510"},{"type":"FIX","url":"https://github.com/novuhq/novu/commit/f105f3d41a4405a75f803634d49f15a967524d8a"},{"type":"FIX","url":"https://github.com/novuhq/novu/pull/11453"},{"type":"PACKAGE","url":"https://github.com/novuhq/novu"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-21T02:59:23.892071753Z"}}