GHSA-8GR3-5J6F-25GP
Vulnerability from github – Published: 2026-09-22 20:34 – Updated: 2026-09-22 20:34Summary
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 (jan@turingpoint.de)
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.17.0"
},
"package": {
"ecosystem": "npm",
"name": "@novu/js"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.18.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-75510"
],
"database_specific": {
"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"
},
"details": "## Summary\n\nThe `@novu/js` In-App Inbox renderer passes a notification\u0027s `redirect.url` to `window.open()` with no URL-scheme validation. The value originates from a notification\u0027s 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:\u003cpayload\u003e\", 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\u0027s `redirect` object with no scheme check. Novu\u0027s 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 \u003c= v3.15.0; `@novu/js` \u003c= 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 \u003ckey\u003e\n{\"name\":\"poc\",\"notificationGroupId\":\"\u003cng\u003e\",\"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\":\"\u003cappId\u003e\",\"subscriberId\":\"\u003csub\u003e\"}\nGET /v1/inbox/notifications Authorization: Bearer \u003csubscriber-jwt\u003e\n-\u003e 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://\u003cdashboard\u003e: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\u0027s id and the executing payload reads `localStorage[\u0027self-hosted-jwt\u0027]` (apps/dashboard/src/utils/self-hosted/jwt-manager.tsx:4), the dashboard session token.\n\n## Impact\n\n- Stored XSS in the recipient\u0027s 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[\u0027self-hosted-jwt\u0027]` 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)",
"id": "GHSA-8gr3-5j6f-25gp",
"modified": "2026-09-22T20:34:35Z",
"published": "2026-09-22T20:34:35Z",
"references": [
{
"type": "WEB",
"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": "WEB",
"url": "https://github.com/novuhq/novu/pull/11453"
},
{
"type": "WEB",
"url": "https://github.com/novuhq/novu/commit/f105f3d41a4405a75f803634d49f15a967524d8a"
},
{
"type": "PACKAGE",
"url": "https://github.com/novuhq/novu"
},
{
"type": "WEB",
"url": "https://github.com/novuhq/novu/releases/tag/v3.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Novu: Stored XSS in In-App Inbox via notification redirect.url javascript: scheme"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.