This is an automated email from the ASF dual-hosted git repository.

rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new f516dde311f NIFI-16059: validate connector postMessage origin against 
the host origin instead of the entity-controlled configurationUrl (CWE-346) 
(#11379)
f516dde311f is described below

commit f516dde311fe95323bb3ca2ff4dd76366a619474
Author: Matt Gilman <[email protected]>
AuthorDate: Tue Jul 14 15:47:28 2026 -0400

    NIFI-16059: validate connector postMessage origin against the host origin 
instead of the entity-controlled configurationUrl (CWE-346) (#11379)
    
    This closes #11379
---
 .../service/connector-message-host.service.spec.ts | 107 +++++++++++----------
 .../service/connector-message-host.service.ts      |  39 ++++----
 .../connector-configure.component.spec.ts          |  41 +++++++-
 .../connector-configure.component.ts               |   8 +-
 .../connector-detail.component.spec.ts             |   1 -
 .../connector-detail/connector-detail.component.ts |   1 -
 6 files changed, 116 insertions(+), 81 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.spec.ts
index 91d7dee335a..2f8733041b8 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.spec.ts
@@ -22,7 +22,8 @@ import { CONNECTOR_MESSAGE_NAMESPACE } from '@nifi/shared';
 import { ConnectorMessageHost, ConnectorMessageHostOptions } from 
'./connector-message-host.service';
 
 describe('ConnectorMessageHost', () => {
-    const TRUSTED_ORIGIN = 'http://localhost:4200';
+    // The host trusts only the application's own origin; tests dispatch from 
it.
+    const TRUSTED_ORIGIN = window.location.origin;
 
     function createMockDestroyRef(): DestroyRef {
         const callbacks: Array<() => void> = [];
@@ -49,7 +50,7 @@ describe('ConnectorMessageHost', () => {
         }).compileComponents();
 
         const service = TestBed.inject(ConnectorMessageHost);
-        const router = TestBed.inject(Router) as { navigate: ReturnType<typeof 
vi.fn> };
+        const router = TestBed.inject(Router) as unknown as { navigate: 
ReturnType<typeof vi.fn> };
 
         const destroyRef = createMockDestroyRef();
 
@@ -58,8 +59,7 @@ describe('ConnectorMessageHost', () => {
 
     function createDefaultOptions(destroyRef: DestroyRef): 
ConnectorMessageHostOptions {
         return {
-            destroyRef,
-            expectedOrigin: TRUSTED_ORIGIN
+            destroyRef
         };
     }
 
@@ -145,7 +145,7 @@ describe('ConnectorMessageHost', () => {
             expect(navigate).not.toHaveBeenCalled();
         });
 
-        it('should reject messages with empty origin when expectedOrigin is 
set', async () => {
+        it('should reject messages with empty origin', async () => {
             const { service, navigate, destroyRef } = await setup();
             service.startListening(createDefaultOptions(destroyRef));
 
@@ -252,7 +252,7 @@ describe('ConnectorMessageHost', () => {
             
expect(onConnectorUiReady.mock.calls[0][0]).toBeInstanceOf(MessageEvent);
         });
 
-        it('should not invoke onConnectorUiReady when origin does not match 
expectedOrigin', async () => {
+        it('should not invoke onConnectorUiReady when origin does not match 
the trusted origin', async () => {
             const onConnectorUiReady = vi.fn();
             const { service, destroyRef } = await setup();
 
@@ -350,23 +350,43 @@ describe('ConnectorMessageHost', () => {
         });
     });
 
-    describe('extractOrigin', () => {
-        it('should extract origin from a valid URL', () => {
-            
expect(ConnectorMessageHost.extractOrigin('https://connector-ui.example.com/config?id=123')).toBe(
-                'https://connector-ui.example.com'
+    describe('origin decoupling (NIFI-16059)', () => {
+        it('should drop messages whose origin matches an attacker-controlled 
configurationUrl', async () => {
+            const { service, navigate, destroyRef } = await setup();
+            service.startListening(createDefaultOptions(destroyRef));
+
+            // Simulate a connector entity whose configurationUrl points at an
+            // attacker origin. Even though that URL would load the iframe, the
+            // host must not trust messages claiming to come from it.
+            const attackerConfigurationUrl = 
'https://attacker.example.com/custom-config';
+            const attackerOrigin = new URL(attackerConfigurationUrl).origin;
+
+            dispatchMessageEvent(
+                {
+                    namespace: CONNECTOR_MESSAGE_NAMESPACE,
+                    type: 'navigate-to-connector-listing',
+                    payload: { connectorId: 'c1' }
+                },
+                attackerOrigin
             );
-        });
 
-        it('should extract origin including port', () => {
-            
expect(ConnectorMessageHost.extractOrigin('http://localhost:4200/wizard')).toBe('http://localhost:4200');
+            expect(navigate).not.toHaveBeenCalled();
         });
 
-        it('should return empty string for invalid URL', () => {
-            expect(ConnectorMessageHost.extractOrigin('not-a-url')).toBe('');
-        });
+        it('should accept messages from the application origin', async () => {
+            const { service, navigate, destroyRef } = await setup();
+            service.startListening(createDefaultOptions(destroyRef));
 
-        it('should return empty string for empty string', () => {
-            expect(ConnectorMessageHost.extractOrigin('')).toBe('');
+            dispatchMessageEvent(
+                {
+                    namespace: CONNECTOR_MESSAGE_NAMESPACE,
+                    type: 'navigate-to-connector-listing',
+                    payload: { connectorId: 'c1' }
+                },
+                window.location.origin
+            );
+
+            expect(navigate).toHaveBeenCalledWith(['/connectors', 'c1']);
         });
     });
 
@@ -395,47 +415,28 @@ describe('ConnectorMessageHost', () => {
         it('should tear down previous listener when startListening is called 
again', async () => {
             const { service, navigate, destroyRef } = await setup();
 
-            const firstOrigin = 'http://first-origin.example.com';
-            const secondOrigin = 'http://second-origin.example.com';
+            // Start first listener (simulates initial route activation)
+            service.startListening(createDefaultOptions(destroyRef));
 
-            service.startListening({
-                destroyRef,
-                expectedOrigin: firstOrigin
+            dispatchMessageEvent({
+                namespace: CONNECTOR_MESSAGE_NAMESPACE,
+                type: 'navigate-to-connector-listing',
+                payload: { connectorId: 'c1' }
             });
-
-            dispatchMessageEvent(
-                {
-                    namespace: CONNECTOR_MESSAGE_NAMESPACE,
-                    type: 'navigate-to-connector-listing',
-                    payload: { connectorId: 'c1' }
-                },
-                firstOrigin
-            );
             expect(navigate).toHaveBeenCalledTimes(1);
 
-            service.startListening({
-                destroyRef,
-                expectedOrigin: secondOrigin
-            });
+            // Start second listener (simulates route param change). The 
previous
+            // subscription must be torn down so handlers do not accumulate.
+            service.startListening(createDefaultOptions(destroyRef));
 
-            dispatchMessageEvent(
-                {
-                    namespace: CONNECTOR_MESSAGE_NAMESPACE,
-                    type: 'navigate-to-connector-listing',
-                    payload: { connectorId: 'c2' }
-                },
-                firstOrigin
-            );
-            expect(navigate).toHaveBeenCalledTimes(1);
+            dispatchMessageEvent({
+                namespace: CONNECTOR_MESSAGE_NAMESPACE,
+                type: 'navigate-to-connector-listing',
+                payload: { connectorId: 'c2' }
+            });
 
-            dispatchMessageEvent(
-                {
-                    namespace: CONNECTOR_MESSAGE_NAMESPACE,
-                    type: 'navigate-to-connector-listing',
-                    payload: { connectorId: 'c3' }
-                },
-                secondOrigin
-            );
+            // Exactly one additional handler invocation, proving the old
+            // subscription was replaced rather than duplicated.
             expect(navigate).toHaveBeenCalledTimes(2);
         });
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.ts
index 67ad465804b..ca08f538641 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector-message-host.service.ts
@@ -16,6 +16,7 @@
  */
 
 import { DestroyRef, Injectable, inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
 import { Router } from '@angular/router';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { Subscription, fromEvent } from 'rxjs';
@@ -26,12 +27,6 @@ export interface ConnectorMessageHostOptions {
     /** Angular DestroyRef for automatic subscription cleanup. */
     destroyRef: DestroyRef;
 
-    /**
-     * The expected origin of the iframe (e.g. 
`'https://connector-ui.example.com'`).
-     * Messages whose `event.origin` does not match are silently dropped.
-     */
-    expectedOrigin: string;
-
     /**
      * Optional lazy getter for the HTMLIFrameElement hosting the custom UI.
      * When provided, the host verifies that `event.source` matches the
@@ -68,20 +63,22 @@ export interface ConnectorMessageHostOptions {
 })
 export class ConnectorMessageHost {
     private router = inject(Router);
+    private readonly document = inject(DOCUMENT);
     private activeSubscription: Subscription | null = null;
 
     /**
-     * Extract the origin from a URL string for use with `expectedOrigin`.
-     * Returns an empty string when the URL cannot be parsed, which will
-     * cause all origin checks to fail-closed (no messages accepted).
+     * The only origin trusted for inbound and outbound connector messages.
+     *
+     * Connector custom UIs are always served by the same NiFi web server that
+     * serves this application, so the iframe is same-origin with the parent.
+     * Trusting the application's own origin -- rather than an origin derived
+     * from the entity-controlled `configurationUrl` / `detailsUrl` -- prevents
+     * a connector entity from attesting to its own message origin (CWE-346).
+     *
+     * The origin is immutable for the application's lifetime, so it is 
captured
+     * once at construction rather than recomputed on every access.
      */
-    static extractOrigin(url: string): string {
-        try {
-            return new URL(url).origin;
-        } catch {
-            return '';
-        }
-    }
+    readonly trustedOrigin = this.document.location.origin;
 
     /**
      * Begin listening for postMessage events.
@@ -91,7 +88,7 @@ export class ConnectorMessageHost {
      * accumulating duplicate subscriptions.
      *
      * Only messages that pass all of the following checks are processed:
-     * 1. `event.origin` matches `expectedOrigin`
+     * 1. `event.origin` matches the application's own origin (`trustedOrigin`)
      * 2. `event.source` matches the iframe's `contentWindow` (when 
`iframeElement` is provided)
      * 3. `isConnectorMessage()` type guard passes (namespace + type 
validation)
      *
@@ -131,7 +128,13 @@ export class ConnectorMessageHost {
      * Validate that the MessageEvent originates from the expected iframe.
      */
     private isAllowedSource(event: MessageEvent, options: 
ConnectorMessageHostOptions): boolean {
-        if (event.origin !== options.expectedOrigin) {
+        // Origin must match the application's own origin exactly. Connector
+        // custom UIs are served same-origin by the backend (see
+        // ConnectorResource#buildCustomUiUrl), so a mismatch means the message
+        // is not from our embedded UI and is dropped (fail-closed). A custom 
UI
+        // whose configurationUrl/detailsUrl were ever cross-origin would 
render
+        // but be unable to communicate.
+        if (event.origin !== this.trustedOrigin) {
             return false;
         }
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
index b47326d9430..882a30edfb8 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
@@ -19,7 +19,13 @@ import { ComponentFixture, TestBed } from 
'@angular/core/testing';
 import { ConnectorConfigure } from './connector-configure.component';
 import { MockStore, provideMockStore } from '@ngrx/store/testing';
 import { DomSanitizer } from '@angular/platform-browser';
-import { ConnectorConfigurationService, ConnectorEntity, ConnectorWizard, 
SystemTokensService } from '@nifi/shared';
+import {
+    CONNECTOR_MESSAGE_NAMESPACE,
+    ConnectorConfigurationService,
+    ConnectorEntity,
+    ConnectorWizard,
+    SystemTokensService
+} from '@nifi/shared';
 import { MockComponent } from 'ng-mocks';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
 import { of, throwError } from 'rxjs';
@@ -155,7 +161,8 @@ describe('ConnectorConfigure', () => {
 
         const mockConnectorMessageHost = {
             startListening: vi.fn(),
-            stopListening: vi.fn()
+            stopListening: vi.fn(),
+            trustedOrigin: window.location.origin
         };
 
         const mockClusterConnectionService = {
@@ -497,7 +504,6 @@ describe('ConnectorConfigure', () => {
             
expect(connectorMessageHost.startListening).toHaveBeenCalledTimes(1);
             expect(connectorMessageHost.startListening).toHaveBeenCalledWith(
                 expect.objectContaining({
-                    expectedOrigin: 'http://localhost:4200',
                     iframeElement: expect.any(Function)
                 })
             );
@@ -545,5 +551,34 @@ describe('ConnectorConfigure', () => {
 
             expect(connectorMessageHost.stopListening).toHaveBeenCalled();
         });
+
+        it('should post the disconnected-node-acknowledgment to the host 
trusted origin, not the entity configurationUrl origin', () => {
+            
connectorConfigurationService.getConnector.mockReturnValue(of(mockConnectorWithCustomUrl));
+            fixture = TestBed.createComponent(ConnectorConfigure);
+            component = fixture.componentInstance;
+            component.ngOnInit();
+
+            // Stub the iframe so the outbound postMessage has a target window.
+            const postMessageSpy = vi.fn();
+            (component as unknown as { iframeRef: () => unknown }).iframeRef = 
() => ({
+                nativeElement: { contentWindow: { postMessage: postMessageSpy 
} }
+            });
+
+            (
+                component as unknown as { 
postDisconnectedNodeAcknowledgmentToChild(): void }
+            ).postDisconnectedNodeAcknowledgmentToChild();
+
+            expect(postMessageSpy).toHaveBeenCalledTimes(1);
+            const [message, targetOrigin] = postMessageSpy.mock.calls[0];
+            // targetOrigin must be the application's own origin, never an 
origin
+            // derived from the entity-controlled configurationUrl.
+            expect(targetOrigin).toBe(window.location.origin);
+            expect(message).toEqual(
+                expect.objectContaining({
+                    namespace: CONNECTOR_MESSAGE_NAMESPACE,
+                    type: 'disconnected-node-acknowledgment'
+                })
+            );
+        });
     });
 });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
index 57694358691..cd9b33b8b38 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
@@ -163,7 +163,6 @@ export class ConnectorConfigure implements OnInit {
 
                     this.connectorMessageHost.startListening({
                         destroyRef: this.destroyRef,
-                        expectedOrigin: 
ConnectorMessageHost.extractOrigin(connector.component.configurationUrl),
                         iframeElement: () => this.iframeRef()?.nativeElement,
                         onConnectorUiReady: () => {
                             this.childConnectorUiReady = true;
@@ -180,10 +179,9 @@ export class ConnectorConfigure implements OnInit {
         if (!iframe?.contentWindow || !configurationUrl) {
             return;
         }
-        const targetOrigin = 
ConnectorMessageHost.extractOrigin(configurationUrl);
-        if (!targetOrigin) {
-            return;
-        }
+        // Target the application's own origin (where the same-origin custom UI
+        // is served), not an origin derived from the entity-controlled URL.
+        const targetOrigin = this.connectorMessageHost.trustedOrigin;
         const message: ParentToConnectorMessage = {
             namespace: CONNECTOR_MESSAGE_NAMESPACE,
             type: 'disconnected-node-acknowledgment',
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
index 9b2a7852824..21339ef229c 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
@@ -455,7 +455,6 @@ describe('ConnectorDetail', () => {
             
expect(connectorMessageHost.startListening).toHaveBeenCalledTimes(1);
             expect(connectorMessageHost.startListening).toHaveBeenCalledWith(
                 expect.objectContaining({
-                    expectedOrigin: 'http://localhost:4200',
                     iframeElement: expect.any(Function)
                 })
             );
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
index 89ac1e1de69..96d29e7a763 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
@@ -108,7 +108,6 @@ export class ConnectorDetail implements OnInit {
 
                     this.connectorMessageHost.startListening({
                         destroyRef: this.destroyRef,
-                        expectedOrigin: 
ConnectorMessageHost.extractOrigin(connector.component.detailsUrl),
                         iframeElement: () => this.iframeRef()?.nativeElement
                     });
                 }

Reply via email to