Novu: Stored XSS in der In-App-Inbox über eine Benachrichtigungs-URL mit `javascript:`-Schema
Ein Organisationsmitglied ohne Sonderrechte hinterlegt in einem v1-Workflow eine Weiterleitungs-URL mit dem Schema javascript: und dem Ziel _self; klickt ein Empfänger die Benachrichtigung in der Inbox an, läuft der Code im Ursprung der einbettenden Anwendung.
Advisory-ID: TP-2026-046
Produkt: Novu (Open-Source-Framework für Produktbenachrichtigungen; betroffen ist die In-App-Inbox @novu/js und deren React-Wrapper)
Schwachstellentyp: Stored Cross-Site Scripting (CWE-79)
CVE: CVE-2026-75510
CVSS 3.1: 8.7 (Hoch) · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Hersteller-Einstufung: Niedrig (im Hersteller-Advisory ist kein CVSS-Vektor hinterlegt)
Hersteller-Advisory: GHSA-8gr3-5j6f-25gp
Betroffene Versionen: @novu/js <= 3.17.0 (verifiziert auf novuhq/novu 3.15.0, Self-Hosted und Cloud)
Behoben in: @novu/js 3.18.0
Gemeldet: 30. Mai 2026
Zusammenfassung
Novu ist ein Open-Source-Framework für Produktbenachrichtigungen; seine In-App-Inbox wird als Bibliothek @novu/js in die Anwendung des Kunden und in das Novu-Dashboard eingebettet. Die Inbox übergab die Weiterleitungs-URL einer Benachrichtigung ohne Schema-Prüfung an window.open(), und der Server lieferte diese URL unverändert aus dem gespeicherten Call-to-Action aus. Ein authentifiziertes Organisationsmitglied ohne Sonderrechte legt damit einen v1-Workflow an, dessen Benachrichtigung die URL javascript:<Payload> mit dem Ziel _self trägt; das v2-Schema von Novu weist genau dieses Schema ab, der v1-Pfad und der Renderer taten es nicht. Klickt ein Empfänger die Benachrichtigung in einem Chromium-Browser an, führt window.open(url, "_self", "noopener noreferrer") den Code im Ursprung des aktuellen Dokuments aus, also in der einbettenden Kundenanwendung oder im Self-Hosted-Dashboard, die beide keine Content Security Policy senden. turingpoint hat die Kette gegen eine reale @novu/js-Inbox live verifiziert und an Novu gemeldet, das sie in @novu/js 3.18.0 behoben hat.
Ursache
packages/js/src/ui/context/InboxContext.tsx:101-122 behandelt jede URL, die nicht mit / beginnt, als absolut und reicht sie ohne Schema-Allowlist an window.open(url, target ?? "_blank", "noopener noreferrer") weiter, wodurch javascript: den Sink erreicht. Gespeist wird der Aufruf vom Klick-Handler in packages/js/src/ui/components/Notification/DefaultNotification.tsx:103, der navigate(notification.redirect?.url, notification.redirect?.target) mit den serverseitig gelieferten Werten aufruft. Serverseitig kopiert apps/api/src/app/inbox/utils/notification-mapper.ts:85-89 cta.data.url und cta.data.target ungeprüft in das Feld redirect, und libs/dal/src/repositories/message-template/message-template.schema.ts:41 deklariert data als Schema.Types.Mixed, sodass der im Typinterface gar nicht vorgesehene Schlüssel target mitgespeichert wird. Der v1-Renderpfad libs/application-generic/src/usecases/compile-in-app-template/compile-in-app-template.usecase.ts:35-36 kompiliert die URL nur mit handlebars und prüft kein Schema, während das v2-Control-Schema in libs/application-generic/src/schemas/control/in-app-control.schema.ts:24-27 die URL gegen redirectUrlRegex validiert und javascript: dort abweist, die Gegenmaßnahme im Produkt also bereits existierte. Das Anlegen eines v1-Workflows verlangt in apps/api/src/app/workflows-v1/workflow-v1.controller.ts:65,202 nur eine Authentifizierung ohne Rollenprüfung, sodass jedes Organisationsmitglied und jeder Inhaber des Umgebungs-API-Keys den Wert in der Standardkonfiguration setzen kann.
Proof of Concept
# 1. v1-Workflow mit Weiterleitungs-CTA anlegen (Mitglieds-Session oder Umgebungs-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. Workflow an einen Empfaenger ausloesen, dann den Feed mit einem aus der
# oeffentlichen Application-ID gemuenzten Subscriber-Token lesen:
POST /v1/inbox/session {"applicationIdentifier":"<appId>","subscriberId":"<sub>"}
GET /v1/inbox/notifications
-> redirect: {"url":"javascript:window.top.__X=document.domain;void 0","target":"_self"}
Die gespeicherte URL wird vom Feed unverändert ausgeliefert; der Klick des Empfängers in der Inbox ruft navigate() und damit window.open(url, "_self", ...) auf, und Chromium führt ein javascript:-Ziel bei _self im Ursprung des aktuellen Dokuments aus. Der Standardwert _blank wird vom Browser blockiert, weshalb der Angreifer das vom Server nicht validierte Feld target setzt.
Auswirkung
- Ausführung beliebigen JavaScripts im Browser jedes Empfängers, der die Benachrichtigung anklickt, im Ursprung der einbettenden Anwendung oder des Self-Hosted-Dashboards, die beide keine Content Security Policy senden.
- Im Self-Hosted-Dashboard liest die Payload
localStorage['self-hosted-jwt']und übernimmt damit das Konto eines anderen Mitglieds oder eines Administrators. - Gespeicherter, dauerhafter Vektor über eine Prinzipiengrenze hinweg: Der Autor von Benachrichtigungsinhalten erreicht die Browser-Sitzungen der Endnutzer, und die Benachrichtigung wird an jeden Empfänger des Workflows repliziert.
- Auslösbar mit dem niedrigsten Recht, das Workflows anlegen darf, in der Standardkonfiguration und mit einem einzigen Klick des Empfängers.
Referenzen
Steckt so etwas in Ihrer Software?
Diese Schwachstelle hat unser Team im Rahmen seiner Arbeit gefunden. Lassen Sie Ihre Anwendungen von denselben Spezialisten prüfen, mit einem Penetrationstest von turingpoint.
