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);
+}