Novu: Stored XSS in In-App Inbox via notification redirect.url javascript: scheme
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS 벡터 정보 없음
상세 설명
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.
- Create a v1 in-app workflow with a redirect CTA carrying a
javascript:URL andtarget=_self:
1POST /v1/workflows Authorization: ApiKey <key> 2{"name":"poc","notificationGroupId":"<ng>","active":true, 3 "steps":[{"template":{"type":"in_app","content":"click me", 4 "cta":{"type":"redirect","data":{ 5 "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):
1POST /v1/inbox/session {"applicationIdentifier":"<appId>","subscriberId":"<sub>"} 2GET /v1/inbox/notifications Authorization: Bearer <subscriber-jwt> 3-> 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)
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 6
링크 내용 불러오는 중…