Skip to content

NIFI-15710 Always allow node identities to read connectors#10998

Open
kevdoran wants to merge 10 commits intoapache:NIFI-15258from
kevdoran:NIFI-15710
Open

NIFI-15710 Always allow node identities to read connectors#10998
kevdoran wants to merge 10 commits intoapache:NIFI-15258from
kevdoran:NIFI-15710

Conversation

@kevdoran
Copy link
Contributor

Summary

NIFI-15710

Tracking

Please complete the following tracking steps prior to pull request creation.

Issue Tracking

Pull Request Tracking

  • Pull Request title starts with Apache NiFi Jira issue number, such as NIFI-00000
  • Pull Request commit message starts with Apache NiFi Jira issue number, as such NIFI-00000
  • [ x Pull request contains commits signed with a registered key indicating Verified status

Pull Request Formatting

  • Pull Request based on current revision of the main branch
  • Pull Request refers to a feature branch with one commit containing changes

Verification

Please indicate the verification steps performed prior to pull request creation.

Build

  • Build completed using ./mvnw clean install -P contrib-check
    • JDK 21
    • JDK 25

Licensing

  • New dependencies are compatible with the Apache License 2.0 according to the License Policy
  • New dependencies are documented in applicable LICENSE and NOTICE files

Documentation

  • Documentation formatting appears as expected in rendered files

@kevdoran kevdoran requested review from markap14 and mcgilman March 12, 2026 16:13
@mcgilman mcgilman added the NIP-11 NIP-11 adds support for Connectors label Mar 12, 2026
@mcgilman
Copy link
Contributor

Will review...

Comment on lines +339 to +342
if (isRequestFromClusterNode()) {
logger.debug("Authorizing READ on Connector[{}] to cluster node [{}]", connectorId, currentUser.getIdentity());
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've verified this fix does allow the API call to proceed however, the response object isn't fully populated because permissions are evaluated again when the Entity/DTO is created and this new check is missing there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mcgilman - I have updated this PR as we discussed to both solve the Entity/DTO creation issue in the service facade layer as well as make the logic for determining when requests come from node identities more reliable by using trusted http headers rather than mtls client cert identity matching against node identities.

Copy link
Contributor

@mcgilman mcgilman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates @kevdoran! Just noted one thing below.

Comment on lines +339 to +347
private boolean isClusterNodeRequest() {
if (!properties.isNode()) {
return false;
}
final String header = httpServletRequest.getHeader(RequestReplicationHeader.CLUSTER_NODE_REQUEST.getHeader());
final boolean result = Boolean.TRUE.toString().equals(header);
logger.debug("isClusterNodeRequest: header=[{}], result={}", header, result);
return result;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private boolean isClusterNodeRequest() {
if (!properties.isNode()) {
return false;
}
final String header = httpServletRequest.getHeader(RequestReplicationHeader.CLUSTER_NODE_REQUEST.getHeader());
final boolean result = Boolean.TRUE.toString().equals(header);
logger.debug("isClusterNodeRequest: header=[{}], result={}", header, result);
return result;
}
private boolean isClusterNodeRequest() {
if (!properties.isNode()) {
return false;
}
if (!isRequestFromClusterNode()) {
return false;
}
final String header = httpServletRequest.getHeader(RequestReplicationHeader.CLUSTER_NODE_REQUEST.getHeader());
final boolean result = Boolean.TRUE.toString().equals(header);
logger.debug("isClusterNodeRequest: header=[{}], result={}", header, result);
return result;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to additionally return false here when the request is not from a cluster node. Extra defense to protect a scenario where the header is presented by a non cluster node.

@kevdoran
Copy link
Contributor Author

@mcgilman I have updated this PR based on our conversation to pick the best approach:

Summary

When ClusteredConnectorRequestReplicator polls connector state across the cluster, the receiving node's ConnectorResource.getConnector() must authorize the request. Node identities lack explicit READ policies on /connectors/{id}, so isRequestFromClusterNode() is used to bypass authorization for direct node-to-node requests.

The existing implementation did exact string matching of TLS certificate DNS SANs against node API addresses, which fails with wildcard certificates (e.g. *.example.svc.cluster.local does not exact-match node-0.example.svc.cluster.local).

Changes:

  • ApplicationResource.isRequestFromClusterNode(): Added RFC 6125 wildcard SAN matching (*.foo.bar matches baz.foo.bar but not baz.quux.foo.bar). Also added a check that X-ProxiedEntitiesChain is absent, which distinguishes direct node requests from user requests being proxied through a node.
  • ConnectorResource.getConnector(): Uses isRequestFromClusterNode() to bypass auth and populate the response with canRead=true for cluster node requests, ensuring the component DTO is included in the response.
  • ClusteredConnectorRequestReplicator: Passes null user to replicate() so no X-ProxiedEntitiesChain header is set, allowing the receiving node to recognize it as a direct node request.
  • ThreadPoolRequestReplicator.updateRequestHeaders(): Handles null user by skipping the proxied entities chain header.

Security Review: Wildcard SAN Matching + Proxied Entities Chain Check

What the code does

isRequestFromClusterNode() is called by ConnectorResource.getConnector() and ParameterContextResource.authorizeReadParameterContext() to bypass resource-specific READ authorization for requests made directly by cluster nodes. When it returns true, the caller skips the normal authorize() check.

The four gates (all must pass)

  1. Clustering configured -- getClusterCoordinator() != null
  2. mTLS authentication -- getAuthenticationCertificates() != null (X.509 client cert in security context)
  3. No proxied entities chain -- X-ProxiedEntitiesChain header absent or blank
  4. Certificate DNS SAN matches a known node -- exact match or RFC 6125 wildcard match

Attack surface analysis

Attack 1: Token/OIDC-authenticated user sends direct request

  • Gate 2 blocks: no X.509 certificates in security context. Safe.

Attack 2: mTLS-authenticated user with their own client cert

  • Gate 2 passes (they have a cert)
  • Gate 3 passes (no proxied entities chain on a direct request)
  • Gate 4 blocks: the user's cert DNS SANs won't match any node API address. User certs typically have SANs like user.example.com or no DNS SANs at all, not *.cluster-node.svc.cluster.local. Safe unless the user somehow obtains a certificate with a DNS SAN that matches a cluster node address, which would require compromise of the CA.

Attack 3: User request replicated through the cluster (normal flow)

  • User sends GET /connectors/{id} to node A
  • isReplicateRequest() returns true, request is replicated to all nodes via ThreadPoolRequestReplicator
  • updateRequestHeaders() sets X-ProxiedEntitiesChain with the user's identity (line 244-245)
  • On the receiving node, isRequestFromClusterNode() is called
  • Gate 2 passes (the node-to-node connection uses mTLS)
  • Gate 3 blocks: the X-ProxiedEntitiesChain header is present. Safe.

Attack 4: User spoofs request-replicated header to skip replication and get serviced locally

  • User sends request with request-replicated: true header
  • isReplicateRequest() returns false, request is serviced locally
  • isRequestFromClusterNode() is called on the same node
  • Gate 2: depends on auth method. If token/OIDC, blocked. If mTLS user cert, passes.
  • Gate 3 passes (user didn't send X-ProxiedEntitiesChain)
  • Gate 4 blocks: user's cert SANs don't match node addresses. Safe.

Attack 5: User spoofs both request-replicated AND X-ProxiedEntitiesChain removal

  • A user cannot "remove" a header that was never there. If they don't send X-ProxiedEntitiesChain, gate 3 passes. But gate 4 still blocks because their cert SANs don't match. Safe.

Attack 6: Cluster node sends request with X-ProxiedEntitiesChain (user-proxied request)

  • This is the normal replication flow. Gate 3 correctly blocks it. The request goes through normal authorization using the proxied user's identity. Correct behavior.

Attack 7: ClusteredConnectorRequestReplicator sends request with null user

  • updateRequestHeaders() skips setting X-ProxiedEntitiesChain when user is null (line 241-253)
  • On the receiving node: gate 2 passes (mTLS), gate 3 passes (no chain), gate 4 passes (node cert SAN matches). Correct behavior -- this is the intended path.

Attack 8: StandardAssetSynchronizer sends request via WebClientService

  • Direct HTTPS call with no X-ProxiedEntitiesChain header (only request-replicated and Accept)
  • On the receiving coordinator: gate 2 passes (mTLS), gate 3 passes (no chain), gate 4 passes (node cert SAN matches via wildcard). Correct behavior.

Wildcard matching correctness

The implementation:

if (clientIdentity.startsWith("*.")) {
    final String wildcardSuffix = clientIdentity.substring(1); // ".foo.bar"
    for (final String nodeAddress : nodeApiAddresses) {
        final int firstDot = nodeAddress.indexOf('.');
        if (firstDot > 0 && nodeAddress.substring(firstDot).equals(wildcardSuffix)) {
  • *.foo.bar matches baz.foo.bar -- correct (firstDot=3, ".foo.bar" == ".foo.bar")
  • *.foo.bar does NOT match baz.quux.foo.bar -- correct (firstDot=3, ".quux.foo.bar" != ".foo.bar")
  • *.foo.bar does NOT match foo.bar -- correct (firstDot=3, ".bar" != ".foo.bar")
  • *.foo.bar does NOT match .foo.bar -- correct (firstDot=0, 0 > 0 is false)
  • * alone (not *.) -- not handled by the wildcard branch, falls through to no-match. Safe.
  • *. (wildcard with empty suffix) -- wildcardSuffix would be "", no node address would have substring(firstDot) equal to "". Safe.

Edge cases

  • Node with no dots in API address (e.g. localhost): firstDot would be -1, the firstDot > 0 check prevents matching. Safe.
  • Empty client identities set: loop doesn't execute, returns false. Safe.
  • Empty node API addresses set: nodeApiAddresses.contains() returns false, inner loop doesn't execute. Safe.

Impact on ParameterContextResource

The ParameterContextResource.authorizeReadParameterContext() also calls isRequestFromClusterNode(). The new proxied-entities-chain check means that user requests replicated through nodes will no longer trigger the bypass -- they'll go through normal authorization. This is more secure than before (previously, any request from a machine with a node cert would bypass auth, even if it was proxying a user).

However, this could be a behavioral change for ParameterContextResource: if StandardAssetSynchronizer ever sends requests with a proxied entities chain, those would now be rejected by gate 3. Looking at the code, StandardAssetSynchronizer uses WebClientService directly and does NOT set X-ProxiedEntitiesChain, so this is not a concern.

Conclusion

No vulnerabilities identified. The approach is sound:

  • Trust is rooted in the TLS certificate (cryptographically verified by the TLS handshake)
  • The X-ProxiedEntitiesChain check correctly distinguishes "node acting on its own behalf" from "node proxying a user request"
  • Wildcard matching is correctly scoped to single-label substitution per RFC 6125
  • All attack vectors are blocked by at least one of the four gates
  • The change is actually more secure than the previous isRequestFromClusterNode() implementation, which didn't check for the proxied entities chain at all

Copy link
Contributor

@mcgilman mcgilman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates @kevdoran! I've verified these changes in a locally running cluster so the changes look good functionally speaking. I've noted a few things we should consider before merging.

return Objects.requireNonNull(requestReplicator, "Request Replicator required");
}

private NiFiUser getNodeUser() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is no longer used. Once removed there are a handful of imports that can be removed.


final Family responseFamily = responseStatusType.getFamily();
if (responseFamily == Family.SERVER_ERROR) {
throw new IOException("Server-side error requesting State for Connector with ID + " + connectorId + ". Status code: " + statusCode + ", reason: " + reason);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connector with ID + " should probably be Connector with ID " here. Not new in this PR but wanted to note it anyways.


verify(serviceFacade).authorizeAccess(any(AuthorizeAccess.class));
verify(serviceFacade).getConnector(CONNECTOR_ID);
verify(serviceFacade).getConnector(CONNECTOR_ID, false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to introduce some coverage that verifies the full flow: cluster node detected → authz bypassed → getConnector(id, true) called?

}

// include the proxied entities header
// include the proxied entities header and strip untrusted headers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible for some additional test coverage for when user is null and non-null?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it probably makes sense to update the javadoc for the user param to indicate it's now nullable.

@param user the user making the request, or null for internal cluster node requests (no proxy chain will be sent)

Comment on lines +368 to +369
if (clusterNodeRequest) {
logger.debug("Bypassing authorization for cluster node request on Connector [{}]", id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this same bypass needed for the connector asset endpoints to support synchronization.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

NIP-11 NIP-11 adds support for Connectors

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants