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 14f095aebdc NIFI-16135 Sanitize URL-derived segments used to build 
nifi-api requests (#11462)
14f095aebdc is described below

commit 14f095aebdc1656098c38c5ff926ba06d93f10f6
Author: Matt Gilman <[email protected]>
AuthorDate: Wed Jul 22 12:43:08 2026 -0400

    NIFI-16135 Sanitize URL-derived segments used to build nifi-api requests 
(#11462)
    
    Add safeApiPath and isSameOriginTarget shared utilities and apply them to
    route/query-derived values in AccessPolicyService, DocumentationService, and
    the content viewer, so untrusted deep-link input cannot redirect 
authenticated
    requests or bypass the same-origin ref check.
---
 .../service/access-policy.service.spec.ts          |  67 ++++++++
 .../service/access-policy.service.ts               |  41 +++--
 .../feature/content-viewer.component.spec.ts       |  45 ++++-
 .../feature/content-viewer.component.ts            |  16 +-
 .../service/documentation.service.spec.ts          | 105 ++++++++++++
 .../documentation/service/documentation.service.ts |  69 +++++---
 .../src/main/frontend/libs/shared/src/index.ts     |   1 +
 .../libs/shared/src/utils/url-safety.utils.spec.ts | 187 +++++++++++++++++++++
 .../libs/shared/src/utils/url-safety.utils.ts      | 145 ++++++++++++++++
 9 files changed, 640 insertions(+), 36 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
index 732d18f5622..d20209b410e 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
@@ -174,5 +174,72 @@ describe('AccessPolicyService', () => {
             
httpMock.expectOne('../nifi-api/policies/read/provenance-data/connectors').flush({});
             httpMock.verify();
         });
+
+        it('should encode an untrusted resourceIdentifier segment', () => {
+            const httpMock = TestBed.inject(HttpTestingController);
+
+            service
+                .getAccessPolicy({
+                    resource: 'processors',
+                    resourceIdentifier: 'a b',
+                    action: Action.Read
+                })
+                .subscribe();
+
+            
httpMock.expectOne('../nifi-api/policies/read/processors/a%20b').flush({});
+            httpMock.verify();
+        });
+
+        it('should reject a path-traversal resourceIdentifier without issuing 
a request', () => {
+            const httpMock = TestBed.inject(HttpTestingController);
+
+            let error: unknown;
+            service
+                .getAccessPolicy({
+                    resource: 'processors',
+                    resourceIdentifier: '../../controller',
+                    action: Action.Read
+                })
+                .subscribe({ error: (e) => (error = e) });
+
+            // the rejection surfaces through the observable error channel 
(deferred), and no request is issued
+            expect(error).toBeInstanceOf(Error);
+            httpMock.verify();
+        });
+    });
+
+    describe('getPolicyComponent', () => {
+        it('should GET the component resource URL with encoded segments', () 
=> {
+            const httpMock = TestBed.inject(HttpTestingController);
+
+            service
+                .getPolicyComponent({
+                    resource: 'processors',
+                    resourceIdentifier: 'proc 1',
+                    action: Action.Write,
+                    policy: 'component'
+                })
+                .subscribe();
+
+            httpMock.expectOne('../nifi-api/processors/proc%201').flush({});
+            httpMock.verify();
+        });
+
+        it('should reject a path-traversal resourceIdentifier without issuing 
a request', () => {
+            const httpMock = TestBed.inject(HttpTestingController);
+
+            let error: unknown;
+            service
+                .getPolicyComponent({
+                    resource: 'processors',
+                    resourceIdentifier: 
'..%2F..%2Fnifi-api%2Fcontroller%2Fconfig',
+                    action: Action.Write,
+                    policy: 'component'
+                })
+                .subscribe({ error: (e) => (error = e) });
+
+            expect(error).toBeInstanceOf(Error);
+            httpMock.verify();
+        });
     });
 });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
index 40a1fbcd658..677e4cdaeba 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
@@ -18,10 +18,11 @@
 import { Injectable, inject } from '@angular/core';
 import { HttpClient, HttpParams } from '@angular/common/http';
 import { Client } from '../../../service/client.service';
-import { Observable } from 'rxjs';
+import { defer, Observable } from 'rxjs';
 import { AccessPolicyEntity, ComponentResourceAction, ResourceAction } from 
'../state/shared';
 import { TenantEntity } from '../../../state/shared';
 import { ClusterConnectionService } from 
'../../../service/cluster-connection.service';
+import { safeApiPath } from '@nifi/shared';
 
 @Injectable({ providedIn: 'root' })
 export class AccessPolicyService {
@@ -98,18 +99,28 @@ export class AccessPolicyService {
     }
 
     getAccessPolicy(resourceAction: ResourceAction): Observable<any> {
-        const transformed = 
this.transformConnectorPolicyResource(resourceAction);
-        const path: string[] = [transformed.action, transformed.resource];
-        if (transformed.resourceIdentifier) {
-            path.push(transformed.resourceIdentifier);
-        }
-        return 
this.httpClient.get(`${AccessPolicyService.API}/policies/${path.join('/')}`);
+        // Deferred so a safeApiPath rejection of untrusted (route-derived) 
input surfaces as an
+        // observable error the caller's catchError can handle, rather than 
throwing synchronously.
+        return defer(() => {
+            const transformed = 
this.transformConnectorPolicyResource(resourceAction);
+            // `resource` may be a composite value (e.g. 
'provenance-data/connectors'); split it so
+            // each atom is validated and encoded individually by safeApiPath.
+            const segments: string[] = [transformed.action, 
...transformed.resource.split('/').filter(Boolean)];
+            if (transformed.resourceIdentifier) {
+                segments.push(transformed.resourceIdentifier);
+            }
+            return 
this.httpClient.get(`${AccessPolicyService.API}/policies/${safeApiPath(...segments)}`);
+        });
     }
 
     getPolicyComponent(resourceAction: ComponentResourceAction): 
Observable<any> {
-        return this.httpClient.get(
-            
`${AccessPolicyService.API}/${resourceAction.resource}/${resourceAction.resourceIdentifier}`
-        );
+        return defer(() => {
+            const path = safeApiPath(
+                ...resourceAction.resource.split('/').filter(Boolean),
+                resourceAction.resourceIdentifier
+            );
+            return this.httpClient.get(`${AccessPolicyService.API}/${path}`);
+        });
     }
 
     updateAccessPolicy(accessPolicy: AccessPolicyEntity, users: 
TenantEntity[], userGroups: TenantEntity[]) {
@@ -123,7 +134,9 @@ export class AccessPolicyService {
             }
         };
 
-        return 
this.httpClient.put(`${AccessPolicyService.API}/policies/${accessPolicy.id}`, 
payload);
+        return defer(() =>
+            
this.httpClient.put(`${AccessPolicyService.API}/policies/${safeApiPath(accessPolicy.id)}`,
 payload)
+        );
     }
 
     deleteAccessPolicy(accessPolicy: AccessPolicyEntity): Observable<any> {
@@ -133,7 +146,11 @@ export class AccessPolicyService {
                 disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged()
             }
         });
-        return 
this.httpClient.delete(`${AccessPolicyService.API}/policies/${accessPolicy.id}`,
 { params });
+        return defer(() =>
+            
this.httpClient.delete(`${AccessPolicyService.API}/policies/${safeApiPath(accessPolicy.id)}`,
 {
+                params
+            })
+        );
     }
 
     getUsers(): Observable<any> {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.spec.ts
index 111cf44d832..64cfd65c597 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.spec.ts
@@ -18,7 +18,7 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 
 import { ContentViewerComponent } from './content-viewer.component';
-import { provideMockStore } from '@ngrx/store/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
 import { contentViewersFeatureKey } from '../state';
 import { viewerOptionsFeatureKey } from '../state/viewer-options';
 import { initialState } from '../state/viewer-options/viewer-options.reducer';
@@ -26,12 +26,14 @@ import { MatSelectModule } from '@angular/material/select';
 import { ReactiveFormsModule } from '@angular/forms';
 import { NoopAnimationsModule } from '@angular/platform-browser/animations';
 import { MatIconModule } from '@angular/material/icon';
-import { NifiTooltipDirective } from '@nifi/shared';
+import { NifiTooltipDirective, selectQueryParams } from '@nifi/shared';
 import { aboutFeatureKey } from '../../../state/about';
 import { initialState as aboutInitialState } from 
'../../../state/about/about.reducer';
 import { currentUserFeatureKey } from '../../../state/current-user';
 import { initialState as currentUserInitialState } from 
'../../../state/current-user/current-user.reducer';
 import { DEFAULT_ROUTER_FEATURENAME } from '@ngrx/router-store';
+import { selectAbout } from '../../../state/about/about.selectors';
+import { setRef } from '../state/content/content.actions';
 
 describe('ContentViewerComponent', () => {
     let component: ContentViewerComponent;
@@ -67,6 +69,45 @@ describe('ContentViewerComponent', () => {
         expect(component).toBeTruthy();
     });
 
+    describe('ref query param origin guard', () => {
+        const instanceUri = 'https://nifi.example.com/nifi-api';
+
+        // The guard lives in the constructor subscription, so override the 
selectors and
+        // build a fresh component instance after wiring the desired 
route/about state.
+        function setupWithRef(ref: string) {
+            const store = TestBed.inject(MockStore);
+            store.overrideSelector(selectAbout, { uri: instanceUri } as never);
+            store.overrideSelector(selectQueryParams, { ref });
+            const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+            const localFixture = 
TestBed.createComponent(ContentViewerComponent);
+            localFixture.detectChanges();
+
+            return { dispatchSpy };
+        }
+
+        it('dispatches setRef for a same-origin ref', () => {
+            const ref = 
'https://nifi.example.com/nifi-api/flowfile-queues/1/flowfiles/2/content';
+            const { dispatchSpy } = setupWithRef(ref);
+
+            expect(dispatchSpy).toHaveBeenCalledWith(setRef({ ref }));
+        });
+
+        it('does not dispatch setRef for a cross-origin ref', () => {
+            const ref = 
'https://evil.example.com/nifi-api/flowfile-queues/1/flowfiles/2/content';
+            const { dispatchSpy } = setupWithRef(ref);
+
+            expect(dispatchSpy).not.toHaveBeenCalledWith(setRef({ ref }));
+        });
+
+        it('does not dispatch setRef for a prefix-spoofing origin', () => {
+            const ref = 
'https://nifi.example.com.evil.com/nifi-api/flowfile-queues/1/flowfiles/2/content';
+            const { dispatchSpy } = setupWithRef(ref);
+
+            expect(dispatchSpy).not.toHaveBeenCalledWith(setRef({ ref }));
+        });
+    });
+
     describe('resolveBaseMediaType', () => {
         it('should resolve structured syntax suffix to base media type', () => 
{
             
expect(component['resolveBaseMediaType']('application/vnd.api+json')).toEqual('application/json');
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts
index 2ed77608aa5..402ed81d3fe 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts
@@ -22,7 +22,15 @@ import { loadContentViewerOptions, resetContentViewerOptions 
} from '../state/vi
 import { FormBuilder, FormGroup } from '@angular/forms';
 import { selectBundledViewerOptions, selectViewerOptions } from 
'../state/viewer-options/viewer-options.selectors';
 import { ContentViewer, HEX_VIEWER_URL, SupportedMimeTypes } from 
'../state/viewer-options';
-import { isDefinedAndNotNull, NiFiCommon, SelectGroup, SelectOption, 
selectQueryParams, TextTip } from '@nifi/shared';
+import {
+    isDefinedAndNotNull,
+    isSameOriginTarget,
+    NiFiCommon,
+    SelectGroup,
+    SelectOption,
+    selectQueryParams,
+    TextTip
+} from '@nifi/shared';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { concatLatestFrom } from '@ngrx/operators';
 import { navigateToBundledContentViewer, resetContent, setRef } from 
'../state/content/content.actions';
@@ -185,8 +193,10 @@ export class ContentViewerComponent implements OnInit, 
OnDestroy {
                             if (dataRef) {
                                 // this check is used to ensure the data ref 
which is supplied through a query
                                 // param will attempt to load content from 
this specific NiFi instance and
-                                // not some other location
-                                return dataRef.startsWith(about.uri);
+                                // not some other location. A prefix 
(startsWith) check is bypassable via a
+                                // look-alike origin, so canonicalize and 
require both the same origin and that
+                                // the ref resolves under the instance URI's 
path.
+                                return isSameOriginTarget(dataRef, about.uri, 
{ requireBasePathPrefix: true });
                             }
 
                             return false;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.spec.ts
new file mode 100644
index 00000000000..66cf6796442
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.spec.ts
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from 
'@angular/common/http/testing';
+import { DocumentationService } from './documentation.service';
+import { DefinitionCoordinates } from '../state';
+
+describe('DocumentationService', () => {
+    function createCoordinates(overrides: Partial<DefinitionCoordinates> = 
{}): DefinitionCoordinates {
+        return {
+            group: 'org.apache.nifi',
+            artifact: 'nifi-standard-nar',
+            version: '1.0.0',
+            type: 'org.apache.nifi.processors.standard.LogAttribute',
+            ...overrides
+        };
+    }
+
+    async function setup() {
+        return {
+            service: TestBed.inject(DocumentationService),
+            httpMock: TestBed.inject(HttpTestingController)
+        };
+    }
+
+    beforeEach(async () => {
+        await TestBed.configureTestingModule({
+            providers: [DocumentationService, provideHttpClient(), 
provideHttpClientTesting()]
+        }).compileComponents();
+    });
+
+    afterEach(() => {
+        TestBed.inject(HttpTestingController).verify();
+    });
+
+    describe('getProcessorDefinition', () => {
+        it('should GET the processor-definition endpoint for the coordinates', 
async () => {
+            const { service, httpMock } = await setup();
+
+            service.getProcessorDefinition(createCoordinates()).subscribe();
+
+            httpMock
+                .expectOne(
+                    (r) =>
+                        r.method === 'GET' &&
+                        r.url ===
+                            
'../nifi-api/flow/processor-definition/org.apache.nifi/nifi-standard-nar/1.0.0/org.apache.nifi.processors.standard.LogAttribute'
+                )
+                .flush({});
+        });
+
+        it('should reject a path-traversal coordinate without issuing a 
request', async () => {
+            const { service } = await setup();
+
+            let error: unknown;
+            service
+                .getProcessorDefinition(createCoordinates({ type: 
'..%2F..%2Fnifi-api%2Fcontroller%2Fconfig' }))
+                .subscribe({ error: (e) => (error = e) });
+
+            expect(error).toBeInstanceOf(Error);
+        });
+    });
+
+    describe('getStepDocumentation', () => {
+        it('should encode a step name containing reserved characters', async 
() => {
+            const { service, httpMock } = await setup();
+
+            service.getStepDocumentation(createCoordinates(), 'Step 
One?').subscribe();
+
+            httpMock
+                .expectOne(
+                    (r) =>
+                        r.method === 'GET' &&
+                        r.url ===
+                            
'../nifi-api/flow/steps/org.apache.nifi/nifi-standard-nar/1.0.0/org.apache.nifi.processors.standard.LogAttribute/Step%20One%3F'
+                )
+                .flush({ stepDocumentation: '## Docs' });
+        });
+
+        it('should reject a path-traversal step name without issuing a 
request', async () => {
+            const { service } = await setup();
+
+            let error: unknown;
+            service.getStepDocumentation(createCoordinates(), 
'../secrets').subscribe({ error: (e) => (error = e) });
+
+            expect(error).toBeInstanceOf(Error);
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.ts
index 2b7c12f2ab2..d94d9509bc8 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/service/documentation.service.ts
@@ -17,7 +17,8 @@
 
 import { Injectable, inject } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
-import { Observable } from 'rxjs';
+import { defer, Observable } from 'rxjs';
+import { safeApiPath } from '@nifi/shared';
 import { ProcessorDefinition } from '../state/processor-definition';
 import { ControllerServiceDefinition } from 
'../state/controller-service-definition';
 import { DefinitionCoordinates } from '../state';
@@ -34,57 +35,87 @@ export class DocumentationService {
 
     private static readonly API: string = '../nifi-api';
 
+    /**
+     * Build the validated/encoded `{group}/{artifact}/{version}/{type}` path 
suffix from
+     * route-derived definition coordinates. Each atom is validated and 
encoded by safeApiPath,
+     * which throws on a path separator or traversal sequence so untrusted 
deep-link input cannot
+     * redirect the authenticated request to an arbitrary same-origin path.
+     */
+    private static coordinatePath(coordinates: DefinitionCoordinates): string {
+        return safeApiPath(coordinates.group, coordinates.artifact, 
coordinates.version, coordinates.type);
+    }
+
     getProcessorDefinition(coordinates: DefinitionCoordinates): 
Observable<ProcessorDefinition> {
-        return this.httpClient.get<ProcessorDefinition>(
-            
`${DocumentationService.API}/flow/processor-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        // Deferred so a safeApiPath rejection of untrusted (route-derived) 
input surfaces as an
+        // observable error the caller's catchError can handle, rather than 
throwing synchronously.
+        return defer(() =>
+            this.httpClient.get<ProcessorDefinition>(
+                
`${DocumentationService.API}/flow/processor-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getControllerServiceDefinition(coordinates: DefinitionCoordinates): 
Observable<ControllerServiceDefinition> {
-        return this.httpClient.get<ControllerServiceDefinition>(
-            
`${DocumentationService.API}/flow/controller-service-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<ControllerServiceDefinition>(
+                
`${DocumentationService.API}/flow/controller-service-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getReportingTaskDefinition(coordinates: DefinitionCoordinates): 
Observable<ReportingTaskDefinition> {
-        return this.httpClient.get<ReportingTaskDefinition>(
-            
`${DocumentationService.API}/flow/reporting-task-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<ReportingTaskDefinition>(
+                
`${DocumentationService.API}/flow/reporting-task-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getFlowRegistryClientDefinition(coordinates: DefinitionCoordinates): 
Observable<FlowRegistryClientDefinition> {
-        return this.httpClient.get<FlowRegistryClientDefinition>(
-            
`${DocumentationService.API}/flow/flow-registry-client-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<FlowRegistryClientDefinition>(
+                
`${DocumentationService.API}/flow/flow-registry-client-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getParameterProviderDefinition(coordinates: DefinitionCoordinates): 
Observable<ParameterProviderDefinition> {
-        return this.httpClient.get<ParameterProviderDefinition>(
-            
`${DocumentationService.API}/flow/parameter-provider-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<ParameterProviderDefinition>(
+                
`${DocumentationService.API}/flow/parameter-provider-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getFlowAnalysisRuleDefinition(coordinates: DefinitionCoordinates): 
Observable<FlowAnalysisRuleDefinition> {
-        return this.httpClient.get<FlowAnalysisRuleDefinition>(
-            
`${DocumentationService.API}/flow/flow-analysis-rule-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<FlowAnalysisRuleDefinition>(
+                
`${DocumentationService.API}/flow/flow-analysis-rule-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getAdditionalDetails(coordinates: DefinitionCoordinates): 
Observable<AdditionalDetailsEntity> {
-        return this.httpClient.get<AdditionalDetailsEntity>(
-            
`${DocumentationService.API}/flow/additional-details/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<AdditionalDetailsEntity>(
+                
`${DocumentationService.API}/flow/additional-details/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getConnectorDefinition(coordinates: DefinitionCoordinates): 
Observable<ConnectorDefinition> {
-        return this.httpClient.get<ConnectorDefinition>(
-            
`${DocumentationService.API}/flow/connector-definition/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}`
+        return defer(() =>
+            this.httpClient.get<ConnectorDefinition>(
+                
`${DocumentationService.API}/flow/connector-definition/${DocumentationService.coordinatePath(coordinates)}`
+            )
         );
     }
 
     getStepDocumentation(coordinates: DefinitionCoordinates, stepName: 
string): Observable<StepDocumentationEntity> {
-        return this.httpClient.get<StepDocumentationEntity>(
-            
`${DocumentationService.API}/flow/steps/${coordinates.group}/${coordinates.artifact}/${coordinates.version}/${coordinates.type}/${stepName}`
+        return defer(() =>
+            this.httpClient.get<StepDocumentationEntity>(
+                
`${DocumentationService.API}/flow/steps/${DocumentationService.coordinatePath(coordinates)}/${safeApiPath(stepName)}`
+            )
         );
     }
 }
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/index.ts 
b/nifi-frontend/src/main/frontend/libs/shared/src/index.ts
index 4588f85596c..b3289c5ad5f 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/index.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/index.ts
@@ -23,3 +23,4 @@ export * from './state';
 export * from './types';
 export * from './utils/connector-permissions.utils';
 export * from './utils/connector-validation.utils';
+export * from './utils/url-safety.utils';
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/utils/url-safety.utils.spec.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/utils/url-safety.utils.spec.ts
new file mode 100644
index 00000000000..496da047d55
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/utils/url-safety.utils.spec.ts
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { isSameOriginTarget, safeApiPath, UnsafeApiPathError } from 
'./url-safety.utils';
+
+describe('safeApiPath', () => {
+    describe('valid segments', () => {
+        it('joins plain segments with /', () => {
+            expect(safeApiPath('read', 'processors', 
'abc-123')).toBe('read/processors/abc-123');
+        });
+
+        it('leaves already-safe values unchanged (preserves existing URL 
shapes)', () => {
+            expect(safeApiPath('read', 'data', 
'connectors')).toBe('read/data/connectors');
+        });
+
+        it('percent-encodes reserved characters within a segment', () => {
+            expect(safeApiPath('step name')).toBe('step%20name');
+            expect(safeApiPath('a?b&c=d')).toBe('a%3Fb%26c%3Dd');
+        });
+
+        it('encodes a single segment', () => {
+            expect(safeApiPath('connector-1')).toBe('connector-1');
+        });
+    });
+
+    describe('traversal and separator rejection', () => {
+        it('rejects a literal .. segment', () => {
+            expect(() => safeApiPath('..')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects a literal . segment', () => {
+            expect(() => safeApiPath('.')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects a segment containing a forward slash', () => {
+            expect(() => safeApiPath('a/b')).toThrow(UnsafeApiPathError);
+            expect(() => 
safeApiPath('../etc/passwd')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects a segment containing a backslash', () => {
+            expect(() => safeApiPath('a\\b')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects a segment that embeds a traversal sequence', () => {
+            expect(() => safeApiPath('foo..bar')).toThrow(UnsafeApiPathError);
+        });
+    });
+
+    describe('pre-encoded traversal rejection', () => {
+        it('rejects url-encoded dot-dot (%2e%2e)', () => {
+            expect(() => safeApiPath('%2e%2e')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects mixed-case url-encoded dot-dot (%2E%2E)', () => {
+            expect(() => safeApiPath('%2E%2E')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects url-encoded forward slash (%2f)', () => {
+            expect(() => safeApiPath('a%2fb')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects url-encoded backslash (%5c)', () => {
+            expect(() => safeApiPath('a%5cb')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects url-encoded traversal path (%2e%2e%2f)', () => {
+            expect(() => 
safeApiPath('%2e%2e%2fetc')).toThrow(UnsafeApiPathError);
+        });
+    });
+
+    describe('malformed / empty input rejection', () => {
+        it('rejects an empty segment', () => {
+            expect(() => safeApiPath('')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects when any of multiple segments is empty', () => {
+            expect(() => safeApiPath('read', '', 
'processors')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects a segment containing a control character', () => {
+            expect(() => safeApiPath('a\u0000b')).toThrow(UnsafeApiPathError);
+        });
+
+        it('rejects a segment that is not a decodable URI component', () => {
+            expect(() => safeApiPath('%')).toThrow(UnsafeApiPathError);
+            expect(() => safeApiPath('%zz')).toThrow(UnsafeApiPathError);
+        });
+    });
+});
+
+describe('isSameOriginTarget', () => {
+    const base = 'https://nifi.example.com/nifi-api';
+
+    it('returns true for a same-origin absolute URL', () => {
+        
expect(isSameOriginTarget('https://nifi.example.com/nifi-api/flowfile-queues/1/content',
 base)).toBe(true);
+    });
+
+    it('returns true for a same-origin URL on a different path', () => {
+        expect(isSameOriginTarget('https://nifi.example.com/other', 
base)).toBe(true);
+    });
+
+    it('returns true for a relative reference resolved against the base', () 
=> {
+        expect(isSameOriginTarget('/nifi-api/flowfile-queues/1/content', 
base)).toBe(true);
+    });
+
+    it('returns false for a different host', () => {
+        expect(isSameOriginTarget('https://evil.example.com/nifi-api/content', 
base)).toBe(false);
+    });
+
+    it('returns false for a prefix-spoofing host', () => {
+        
expect(isSameOriginTarget('https://nifi.example.com.evil.com/nifi-api/content', 
base)).toBe(false);
+    });
+
+    it('returns false for a different scheme', () => {
+        expect(isSameOriginTarget('http://nifi.example.com/nifi-api/content', 
base)).toBe(false);
+    });
+
+    it('returns false for a different port', () => {
+        
expect(isSameOriginTarget('https://nifi.example.com:8443/nifi-api/content', 
base)).toBe(false);
+    });
+
+    it('returns false when the candidate is an invalid absolute URL', () => {
+        expect(isSameOriginTarget('http://', base)).toBe(false);
+    });
+
+    it('returns false when the base cannot be parsed', () => {
+        expect(isSameOriginTarget('https://nifi.example.com/x', 
'not-an-absolute-url')).toBe(false);
+    });
+
+    describe('requireBasePathPrefix', () => {
+        it('returns true for a same-origin ref that resolves under the base 
path', () => {
+            expect(
+                
isSameOriginTarget('https://nifi.example.com/nifi-api/flowfile-queues/1/content',
 base, {
+                    requireBasePathPrefix: true
+                })
+            ).toBe(true);
+        });
+
+        it('returns false for a same-origin ref outside the base path', () => {
+            expect(
+                isSameOriginTarget('https://nifi.example.com/other/content', 
base, { requireBasePathPrefix: true })
+            ).toBe(false);
+        });
+
+        it('still returns false for a cross-origin ref', () => {
+            expect(
+                
isSameOriginTarget('https://evil.example.com/nifi-api/content', base, { 
requireBasePathPrefix: true })
+            ).toBe(false);
+        });
+
+        it('returns false for a same-origin ref that escapes the base path via 
../ traversal', () => {
+            expect(
+                
isSameOriginTarget('https://nifi.example.com/nifi-api/../internal-status', 
base, {
+                    requireBasePathPrefix: true
+                })
+            ).toBe(false);
+        });
+
+        it('returns false for a sibling path that shares the base as a string 
prefix', () => {
+            expect(
+                
isSameOriginTarget('https://nifi.example.com/nifi-api-evil/content', base, {
+                    requireBasePathPrefix: true
+                })
+            ).toBe(false);
+        });
+
+        it('returns true when the ref resolves to exactly the base path', () 
=> {
+            expect(isSameOriginTarget('https://nifi.example.com/nifi-api', 
base, { requireBasePathPrefix: true })).toBe(
+                true
+            );
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/utils/url-safety.utils.ts 
b/nifi-frontend/src/main/frontend/libs/shared/src/utils/url-safety.utils.ts
new file mode 100644
index 00000000000..98f01d01f51
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/utils/url-safety.utils.ts
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Error thrown by {@link safeApiPath} when an untrusted path segment fails
+ * validation. Callers fail closed (no request is issued) rather than
+ * interpolating a potentially malicious value into an authenticated API URL.
+ */
+export class UnsafeApiPathError extends Error {
+    constructor(segment: string, reason: string) {
+        super(`Unsafe API path segment [${segment}]: ${reason}`);
+        this.name = 'UnsafeApiPathError';
+    }
+}
+
+// Matches ASCII control characters (including NUL) which must never appear in 
a
+// path segment.
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
+
+/**
+ * Returns `true` when the given (already url-decoded) value contains a path
+ * separator or traversal sequence and therefore must not be used as a single
+ * path segment.
+ *
+ * Note: this rejects any value that merely *embeds* `..` (e.g. `foo..bar`), 
not
+ * only a literal `..` segment. This is intentional and safe for the intended
+ * callers, whose segments are identifiers (UUIDs / decimal ids) or enumerated
+ * NiFi resource names -- none of which legitimately contain `..`.
+ */
+function containsTraversal(value: string): boolean {
+    return value === '.' || value.includes('/') || value.includes('\\') || 
value.includes('..');
+}
+
+/**
+ * Validate and percent-encode untrusted strings for use as path segments of an
+ * authenticated API URL.
+ *
+ * Each argument is treated as a single, atomic path segment. Callers with a
+ * composite value (for example a policy resource such as
+ * `provenance-data/connectors`) must split it on `/` and pass each atom
+ * individually so every atom is validated and encoded.
+ *
+ * A segment is rejected (via {@link UnsafeApiPathError}) when it is empty,
+ * contains a `/` or `\`, embeds a `..` traversal sequence, contains an ASCII
+ * control character, is not decodable, or decodes to a value that itself
+ * contains a traversal sequence (defends against pre-encoded traversal such as
+ * `%2e%2e` or `%2f`). Segments are assumed to be identifiers or enumerated
+ * resource names.
+ *
+ * @returns the surviving segments `encodeURIComponent`-encoded and joined 
with `/`.
+ */
+export function safeApiPath(...segments: string[]): string {
+    return segments
+        .map((segment) => {
+            if (segment === null || segment === undefined || segment.length 
=== 0) {
+                throw new UnsafeApiPathError(String(segment), 'segment is 
empty');
+            }
+
+            if (CONTROL_CHARS.test(segment)) {
+                throw new UnsafeApiPathError(segment, 'segment contains 
control characters');
+            }
+
+            if (containsTraversal(segment)) {
+                throw new UnsafeApiPathError(segment, 'segment contains a path 
separator or traversal sequence');
+            }
+
+            let decoded: string;
+            try {
+                decoded = decodeURIComponent(segment);
+            } catch {
+                throw new UnsafeApiPathError(segment, 'segment is not a valid 
URI component');
+            }
+
+            if (containsTraversal(decoded)) {
+                throw new UnsafeApiPathError(segment, 'segment decodes to a 
path separator or traversal sequence');
+            }
+
+            return encodeURIComponent(segment);
+        })
+        .join('/');
+}
+
+/**
+ * Determine whether `candidate` resolves to the same origin as `base`.
+ *
+ * Both values are canonicalized with the URL constructor (`candidate` is
+ * resolved relative to `base`) and their origins compared. Returns `false`
+ * when either value cannot be parsed, so callers fail closed on malformed
+ * input rather than trusting a value that merely string-prefix-matches a
+ * trusted URL.
+ *
+ * When `options.requireBasePathPrefix` is set, the candidate must additionally
+ * resolve under `base`'s path. The check is segment-boundary aware -- the
+ * candidate's `pathname` must equal `base`'s `pathname` or start with it
+ * followed by a `/` -- so a base of `/nifi-api` does not match a sibling path
+ * such as `/nifi-api-evil`. This preserves the scoping of a legacy
+ * `startsWith(base)` guard while still closing look-alike-origin bypasses.
+ */
+export function isSameOriginTarget(
+    candidate: string,
+    base: string,
+    options: { requireBasePathPrefix?: boolean } = {}
+): boolean {
+    try {
+        const baseUrl = new URL(base);
+        const candidateUrl = new URL(candidate, baseUrl);
+        if (candidateUrl.origin !== baseUrl.origin) {
+            return false;
+        }
+        if (options.requireBasePathPrefix && 
!isPathUnder(candidateUrl.pathname, baseUrl.pathname)) {
+            return false;
+        }
+        return true;
+    } catch {
+        return false;
+    }
+}
+
+/**
+ * Returns `true` when `pathname` is the same as, or a descendant of, 
`basePath`.
+ * The comparison respects path-segment boundaries so `/nifi-api` does not 
match
+ * `/nifi-api-evil` (but does match `/nifi-api` and `/nifi-api/flow`).
+ */
+function isPathUnder(pathname: string, basePath: string): boolean {
+    if (pathname === basePath) {
+        return true;
+    }
+    const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`;
+    return pathname.startsWith(normalizedBase);
+}


Reply via email to