Novu: Stored XSS in the In-App Inbox via a notification URL with a `javascript:` scheme

An organization member without special rights stores a redirect URL with the javascript: scheme and target _self in a v1 workflow; when a recipient clicks the notification in the inbox, the code runs in the origin of the embedding application.

Advisory ID: TP-2026-046
Product: Novu (open-source framework for product notifications; the affected part is the In-App Inbox @novu/js and its React wrapper)
Vulnerability type: Stored cross-site scripting (CWE-79)
CVE: CVE-2026-75510
CVSS 3.1: 8.7 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Vendor rating: Low (the vendor advisory carries no CVSS vector)
Vendor advisory: GHSA-8gr3-5j6f-25gp
Affected versions: @novu/js <= 3.17.0 (verified on novuhq/novu 3.15.0, self-hosted and cloud)
Fixed in: @novu/js 3.18.0
Reported: 30 May 2026

Summary

Novu is an open-source framework for product notifications; its In-App Inbox ships as the library @novu/js and is embedded into the customer application and into the Novu dashboard. The inbox passed a notification's redirect URL to window.open() without validating the URL scheme, and the server delivered that URL verbatim from the stored call-to-action. An authenticated organization member without special rights therefore creates a v1 workflow whose notification carries the URL javascript:<payload> with target _self; Novu's own v2 schema rejects exactly this scheme, while the v1 path and the renderer did not. When a recipient clicks the notification in a Chromium browser, window.open(url, "_self", "noopener noreferrer") executes the code in the current document origin, that is in the embedding customer application or in the self-hosted dashboard, neither of which sends a Content Security Policy. turingpoint verified the chain live against a real @novu/js inbox and reported it to Novu, which fixed it in @novu/js 3.18.0.

Root cause

packages/js/src/ui/context/InboxContext.tsx:101-122 treats every URL that does not start with / as absolute and forwards it to window.open(url, target ?? "_blank", "noopener noreferrer") with no scheme allowlist, so javascript: reaches the sink. The call is fed by the click handler in packages/js/src/ui/components/Notification/DefaultNotification.tsx:103, which invokes navigate(notification.redirect?.url, notification.redirect?.target) with the values delivered by the server. On the server, apps/api/src/app/inbox/utils/notification-mapper.ts:85-89 copies cta.data.url and cta.data.target into the redirect object without validating either, and libs/dal/src/repositories/message-template/message-template.schema.ts:41 declares data as Schema.Types.Mixed, so the target key that the typed interface does not even declare is persisted. The v1 render path in libs/application-generic/src/usecases/compile-in-app-template/compile-in-app-template.usecase.ts:35-36 only handlebars-compiles the URL and performs no scheme check, while the v2 control schema in libs/application-generic/src/schemas/control/in-app-control.schema.ts:24-27 validates the URL against redirectUrlRegex and rejects javascript: there, so the countermeasure already existed in the product. Creating a v1 workflow requires only authentication and no role check in apps/api/src/app/workflows-v1/workflow-v1.controller.ts:65,202, so any organization member and any holder of the environment API key can set the value in the default configuration.

Proof of Concept

# 1. Create a v1 workflow with a redirect CTA (member session or environment API key):
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 the workflow to a recipient, then read the feed with a subscriber
#    token minted from the public application identifier:
POST /v1/inbox/session   {"applicationIdentifier":"<appId>","subscriberId":"<sub>"}
GET  /v1/inbox/notifications
-> redirect: {"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}

The stored URL is returned by the feed unmodified; the recipient's click calls navigate() and therefore window.open(url, "_self", ...), and Chromium executes a javascript: target with _self in the current document origin. The default value _blank is blocked by the browser, which is why the attacker sets the target field that the server does not validate.

Impact

  • Execution of arbitrary JavaScript in the browser of every recipient who clicks the notification, in the origin of the embedding application or of the self-hosted dashboard, neither of which sends a Content Security Policy.
  • On the self-hosted dashboard the payload reads localStorage['self-hosted-jwt'] and takes over the account of another member or of an administrator.
  • Stored, persistent vector across a principal boundary: the author of notification content reaches the browser sessions of end users, and the notification is replicated to every recipient of the workflow.
  • Triggered with the lowest privilege that may author workflows, in the default configuration, with a single click by the recipient.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications reviewed by the same specialists, with a penetration test from turingpoint.