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

exceptionfactory 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 a43e49d967b NIFI-16220 Improved downloaded file handling in NiFi 
client modules (#11560)
a43e49d967b is described below

commit a43e49d967be2083905a207ef247a4f3f323f7b7
Author: Pierre Villard <[email protected]>
AuthorDate: Wed Aug 19 22:48:41 2026 +0200

    NIFI-16220 Improved downloaded file handling in NiFi client modules (#11560)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../nifi/registry/client/impl/ClientUtils.java     |  48 ++++++++-
 .../nifi/registry/client/impl/ClientUtilsTest.java | 112 +++++++++++++++++++++
 .../toolkit/client/impl/AbstractJerseyClient.java  |  50 +++++++--
 .../toolkit/client/impl/JerseyConnectorClient.java |   3 +-
 .../client/impl/JerseyControllerClient.java        |   3 +-
 .../client/impl/JerseyParamContextClient.java      |   3 +-
 .../client/impl/AbstractJerseyClientTest.java      | 106 +++++++++++++++++++
 .../client/impl/JerseyControllerClientTest.java    |  99 ++++++++++++++++++
 8 files changed, 407 insertions(+), 17 deletions(-)

diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/ClientUtils.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/ClientUtils.java
index aeb160d653b..7f703651b29 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/ClientUtils.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/ClientUtils.java
@@ -18,23 +18,31 @@ package org.apache.nifi.registry.client.impl;
 
 import jakarta.ws.rs.core.Response;
 import org.apache.commons.lang3.StringUtils;
+import org.glassfish.jersey.media.multipart.ContentDisposition;
 
 import java.io.File;
 import java.io.InputStream;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
+import java.text.ParseException;
+import java.util.regex.Pattern;
 
 public class ClientUtils {
 
+    private static final char FORWARD_SLASH = '/';
+
+    private static final char BACKWARD_SLASH = '\\';
+
+    private static final Pattern FILENAME_PARAMETER_PATTERN = 
Pattern.compile(";\\s*filename\\s*=\\s*", Pattern.CASE_INSENSITIVE);
+
     public static File getExtensionBundleVersionContent(final Response 
response, final File outputDirectory) {
         final String contentDispositionHeader = 
response.getHeaderString("Content-Disposition");
         if (StringUtils.isBlank(contentDispositionHeader)) {
             throw new IllegalStateException("Content-Disposition header was 
blank or missing");
         }
 
-        final int equalsIndex = contentDispositionHeader.lastIndexOf("=");
-        final String filename = contentDispositionHeader.substring(equalsIndex 
+ 1).trim();
-        final File bundleFile = new File(outputDirectory, filename);
+        final File bundleFile = 
getContentDispositionFile(contentDispositionHeader, outputDirectory);
 
         try (final InputStream responseInputStream = 
response.readEntity(InputStream.class)) {
             Files.copy(responseInputStream, bundleFile.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
@@ -44,4 +52,38 @@ public class ClientUtils {
         }
     }
 
+    private static File getContentDispositionFile(final String 
contentDispositionHeader, final File outputDirectory) {
+        if (contentDispositionHeader.indexOf(BACKWARD_SLASH) >= 0) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid");
+        }
+
+        final String filename;
+        try {
+            final String normalizedHeader = 
FILENAME_PARAMETER_PATTERN.matcher(contentDispositionHeader).replaceFirst("; 
filename=");
+            filename = new ContentDisposition(normalizedHeader).getFileName();
+        } catch (final ParseException e) {
+            throw new IllegalStateException("Content-Disposition header was 
invalid", e);
+        }
+
+        if (StringUtils.isBlank(filename) || filename.equals(".") || 
filename.equals("..")
+                || filename.indexOf(FORWARD_SLASH) >= 0 || 
filename.indexOf(BACKWARD_SLASH) >= 0
+                || filename.chars().anyMatch(character -> 
Character.isISOControl(character))) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid");
+        }
+
+        final Path outputPath = 
outputDirectory.toPath().toAbsolutePath().normalize();
+        final Path filenamePath;
+        try {
+            filenamePath = Path.of(filename);
+        } catch (final RuntimeException e) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid", e);
+        }
+
+        final Path bundlePath = outputPath.resolve(filenamePath).normalize();
+        if (filenamePath.isAbsolute() || filenamePath.getNameCount() != 1 || 
!outputPath.equals(bundlePath.getParent())) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid");
+        }
+        return bundlePath.toFile();
+    }
+
 }
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-client/src/test/java/org/apache/nifi/registry/client/impl/ClientUtilsTest.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-client/src/test/java/org/apache/nifi/registry/client/impl/ClientUtilsTest.java
new file mode 100644
index 00000000000..c69778d61bb
--- /dev/null
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-client/src/test/java/org/apache/nifi/registry/client/impl/ClientUtilsTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.registry.client.impl;
+
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ClientUtilsTest {
+
+    private static final byte[] CONTENT = 
"bundle-content".getBytes(StandardCharsets.UTF_8);
+
+    @TempDir
+    private Path outputDirectory;
+
+    @Test
+    void testWriteBundleContent() throws Exception {
+        final Response response = getResponse("attachment; filename = 
example-1.0.nar");
+
+        final File bundleFile = 
ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile());
+
+        assertEquals(outputDirectory.resolve("example-1.0.nar"), 
bundleFile.toPath());
+        assertArrayEquals(CONTENT, Files.readAllBytes(bundleFile.toPath()));
+    }
+
+    @Test
+    void testWriteBundleContentReplacesExistingFile() throws Exception {
+        final Path bundlePath = outputDirectory.resolve("example-1.0.nar");
+        Files.writeString(bundlePath, "existing");
+        final Response response = getResponse("attachment; 
filename=\"example-1.0.nar\"");
+
+        ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile());
+
+        assertArrayEquals(CONTENT, Files.readAllBytes(bundlePath));
+    }
+
+    @Test
+    void testRejectTraversalBeforeReadingContent() {
+        final Response response = getResponse("attachment; 
filename=../outside.nar");
+        final Path outsidePath = 
outputDirectory.resolve("../outside.nar").normalize();
+
+        assertThrows(IllegalStateException.class, () -> 
ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile()));
+
+        assertFalse(Files.exists(outsidePath));
+        verify(response, never()).readEntity(ByteArrayInputStream.class);
+        verify(response, never()).readEntity(java.io.InputStream.class);
+    }
+
+    @Test
+    void testRejectWindowsTraversal() {
+        final Response response = getResponse("attachment; 
filename=..\\outside.nar");
+
+        assertThrows(IllegalStateException.class, () -> 
ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile()));
+    }
+
+    @Test
+    void testRejectAbsolutePath() {
+        final Response response = getResponse("attachment; 
filename=/outside.nar");
+
+        assertThrows(IllegalStateException.class, () -> 
ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile()));
+    }
+
+    @Test
+    void testRejectControlCharacter() {
+        final Response response = getResponse("attachment; 
filename=control\u0001.nar");
+
+        assertThrows(IllegalStateException.class, () -> 
ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile()));
+    }
+
+    @Test
+    void testRejectMissingHeader() {
+        final Response response = mock(Response.class);
+
+        assertThrows(IllegalStateException.class, () -> 
ClientUtils.getExtensionBundleVersionContent(response, 
outputDirectory.toFile()));
+    }
+
+    private Response getResponse(final String contentDisposition) {
+        final Response response = mock(Response.class);
+        
when(response.getHeaderString("Content-Disposition")).thenReturn(contentDisposition);
+        when(response.readEntity(java.io.InputStream.class)).thenReturn(new 
ByteArrayInputStream(CONTENT));
+        return response;
+    }
+}
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClient.java
index 857077c6c8a..8adefba26a7 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClient.java
@@ -23,10 +23,15 @@ import jakarta.ws.rs.core.Response;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.toolkit.client.NiFiClientException;
 import org.apache.nifi.toolkit.client.RequestConfig;
+import org.glassfish.jersey.media.multipart.ContentDisposition;
 
+import java.io.File;
 import java.io.IOException;
+import java.nio.file.Path;
+import java.text.ParseException;
 import java.util.Collections;
 import java.util.Map;
+import java.util.regex.Pattern;
 
 /**
  * Base class for the client operations to share exception handling.
@@ -38,6 +43,12 @@ public class AbstractJerseyClient {
 
     private static final RequestConfig EMPTY_REQUEST_CONFIG = () -> 
Collections.emptyMap();
 
+    private static final char FORWARD_SLASH = '/';
+
+    private static final char BACKWARD_SLASH = '\\';
+
+    private static final Pattern FILENAME_PARAMETER_PATTERN = 
Pattern.compile(";\\s*filename\\s*=\\s*", Pattern.CASE_INSENSITIVE);
+
     private final RequestConfig requestConfig;
 
     public AbstractJerseyClient(final RequestConfig requestConfig) {
@@ -139,18 +150,41 @@ public class AbstractJerseyClient {
         if (StringUtils.isBlank(contentDispositionHeader)) {
             throw new IllegalStateException("Content-Disposition header was 
blank or missing");
         }
+        if (contentDispositionHeader.indexOf(BACKWARD_SLASH) >= 0) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid");
+        }
 
-        final int equalsIndex = contentDispositionHeader.lastIndexOf("=");
-        final String filenameValue = 
contentDispositionHeader.substring(equalsIndex + 1).trim();
+        final String filename;
+        try {
+            final String normalizedHeader = 
FILENAME_PARAMETER_PATTERN.matcher(contentDispositionHeader).replaceFirst("; 
filename=");
+            filename = new ContentDisposition(normalizedHeader).getFileName();
+        } catch (final ParseException e) {
+            throw new IllegalStateException("Content-Disposition header was 
invalid", e);
+        }
 
-        final StringBuilder filename = new StringBuilder(filenameValue);
-        if (!filename.isEmpty() && filename.charAt(0) == '"') {
-            filename.deleteCharAt(0);
+        if (StringUtils.isBlank(filename) || filename.equals(".") || 
filename.equals("..")
+                || filename.indexOf(FORWARD_SLASH) >= 0 || 
filename.indexOf(BACKWARD_SLASH) >= 0
+                || filename.chars().anyMatch(character -> 
Character.isISOControl(character))) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid");
         }
-        if (!filename.isEmpty() && filename.charAt(filename.length() - 1) == 
'"') {
-            filename.setLength(filename.length() - 1);
+        return filename;
+    }
+
+    protected File getContentDispositionFile(final Response response, final 
File outputDirectory) {
+        final String filename = getContentDispositionFilename(response);
+        final Path outputPath = 
outputDirectory.toPath().toAbsolutePath().normalize();
+        final Path filenamePath;
+        try {
+            filenamePath = Path.of(filename);
+        } catch (final RuntimeException e) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid", e);
+        }
+
+        final Path destinationPath = 
outputPath.resolve(filenamePath).normalize();
+        if (filenamePath.isAbsolute() || filenamePath.getNameCount() != 1 || 
!outputPath.equals(destinationPath.getParent())) {
+            throw new IllegalStateException("Content-Disposition filename was 
invalid");
         }
-        return filename.toString();
+        return destinationPath.toFile();
     }
 
 }
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
index 30abe262863..036aa1096d3 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
@@ -744,8 +744,7 @@ public class JerseyConnectorClient extends 
AbstractJerseyClient implements Conne
                 .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)
                 .get();
 
-            final String filename = getContentDispositionFilename(response);
-            final File assetFile = new File(outputDirectory, filename);
+            final File assetFile = getContentDispositionFile(response, 
outputDirectory);
 
             try (final InputStream responseInputStream = 
response.readEntity(InputStream.class)) {
                 Files.copy(responseInputStream, assetFile.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClient.java
index c2d8e08362d..721e0f75ab4 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClient.java
@@ -560,8 +560,7 @@ public class JerseyControllerClient extends 
AbstractJerseyClient implements Cont
                     .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)
                     .get();
 
-            final String filename = getContentDispositionFilename(response);
-            final File narFile = new File(outputDirectory, filename);
+            final File narFile = getContentDispositionFile(response, 
outputDirectory);
 
             try (final InputStream responseInputStream = 
response.readEntity(InputStream.class)) {
                 Files.copy(responseInputStream, narFile.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyParamContextClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyParamContextClient.java
index 102fd663bb1..2db3224c80a 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyParamContextClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyParamContextClient.java
@@ -232,8 +232,7 @@ public class JerseyParamContextClient extends 
AbstractJerseyClient implements Pa
                     .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)
                     .get();
 
-            final String filename = getContentDispositionFilename(response);
-            final File assetFile = new File(outputDirectory, filename);
+            final File assetFile = getContentDispositionFile(response, 
outputDirectory);
 
             try (final InputStream responseInputStream = 
response.readEntity(InputStream.class)) {
                 Files.copy(responseInputStream, assetFile.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/test/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClientTest.java
 
b/nifi-toolkit/nifi-toolkit-client/src/test/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClientTest.java
new file mode 100644
index 00000000000..ed99864d54c
--- /dev/null
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/test/java/org/apache/nifi/toolkit/client/impl/AbstractJerseyClientTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.toolkit.client.impl;
+
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.File;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class AbstractJerseyClientTest {
+
+    @TempDir
+    private Path outputDirectory;
+
+    private final TestJerseyClient client = new TestJerseyClient();
+
+    @Test
+    void testGetContentDispositionFile() {
+        final Response response = getResponse("attachment; filename=\"example 
file-1.0.nar\"");
+
+        final File destination = client.getContentDispositionFile(response, 
outputDirectory.toFile());
+
+        assertEquals(outputDirectory.resolve("example file-1.0.nar"), 
destination.toPath());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "../outside.nar",
+            "..\\outside.nar",
+            "nested/outside.nar",
+            "nested\\outside.nar",
+            "/outside.nar",
+            ".",
+            "..",
+            "control\u0001.nar"
+    })
+    void testRejectUnsafeContentDispositionFilename(final String filename) {
+        final Response response = getResponse("attachment; 
filename=\"%s\"".formatted(filename));
+
+        assertThrows(IllegalStateException.class, () -> 
client.getContentDispositionFile(response, outputDirectory.toFile()));
+    }
+
+    @Test
+    void testRejectMissingFilename() {
+        final Response response = getResponse("attachment");
+
+        assertThrows(IllegalStateException.class, () -> 
client.getContentDispositionFile(response, outputDirectory.toFile()));
+    }
+
+    @Test
+    void testRejectMissingHeader() {
+        final Response response = mock(Response.class);
+
+        assertThrows(IllegalStateException.class, () -> 
client.getContentDispositionFile(response, outputDirectory.toFile()));
+    }
+
+    @Test
+    void testGetContentDispositionFileUnquoted() {
+        final Response response = getResponse("attachment; filename = 
example-1.0.nar");
+
+        final File destination = client.getContentDispositionFile(response, 
outputDirectory.toFile());
+
+        assertEquals(outputDirectory.resolve("example-1.0.nar"), 
destination.toPath());
+    }
+
+    private Response getResponse(final String contentDisposition) {
+        final Response response = mock(Response.class);
+        
when(response.getHeaderString("Content-Disposition")).thenReturn(contentDisposition);
+        return response;
+    }
+
+    private static class TestJerseyClient extends AbstractJerseyClient {
+
+        TestJerseyClient() {
+            super(null);
+        }
+
+        @Override
+        protected File getContentDispositionFile(final Response response, 
final File outputDirectory) {
+            return super.getContentDispositionFile(response, outputDirectory);
+        }
+    }
+}
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/test/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClientTest.java
 
b/nifi-toolkit/nifi-toolkit-client/src/test/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClientTest.java
new file mode 100644
index 00000000000..5b696d48f22
--- /dev/null
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/test/java/org/apache/nifi/toolkit/client/impl/JerseyControllerClientTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.toolkit.client.impl;
+
+import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class JerseyControllerClientTest {
+
+    private static final String IDENTIFIER = "nar-id";
+
+    private static final byte[] CONTENT = 
"nar-content".getBytes(StandardCharsets.UTF_8);
+
+    @TempDir
+    private Path outputDirectory;
+
+    private Invocation.Builder requestBuilder;
+
+    private JerseyControllerClient client;
+
+    @BeforeEach
+    void setUp() {
+        final WebTarget baseTarget = mock(WebTarget.class);
+        final WebTarget controllerTarget = mock(WebTarget.class);
+        final WebTarget contentTarget = mock(WebTarget.class);
+        requestBuilder = mock(Invocation.Builder.class);
+
+        when(baseTarget.path("/controller")).thenReturn(controllerTarget);
+        
when(controllerTarget.path("nar-manager/nars/{identifier}/content")).thenReturn(contentTarget);
+        when(contentTarget.resolveTemplate("identifier", 
IDENTIFIER)).thenReturn(contentTarget);
+        when(contentTarget.request()).thenReturn(requestBuilder);
+        
when(requestBuilder.accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)).thenReturn(requestBuilder);
+        client = new JerseyControllerClient(baseTarget);
+    }
+
+    @Test
+    void testDownloadNarReplacesExistingFile() throws Exception {
+        final Path narPath = outputDirectory.resolve("example-1.0.nar");
+        Files.writeString(narPath, "existing");
+        final Response response = getResponse("attachment; 
filename=\"example-1.0.nar\"");
+        when(requestBuilder.get()).thenReturn(response);
+
+        final File downloaded = client.downloadNar(IDENTIFIER, 
outputDirectory.toFile());
+
+        assertArrayEquals(CONTENT, Files.readAllBytes(downloaded.toPath()));
+    }
+
+    @Test
+    void testDownloadNarRejectsTraversalBeforeReadingContent() {
+        final Response response = getResponse("attachment; 
filename=../outside.nar");
+        when(requestBuilder.get()).thenReturn(response);
+        final Path outsidePath = 
outputDirectory.resolve("../outside.nar").normalize();
+
+        assertThrows(Exception.class, () -> client.downloadNar(IDENTIFIER, 
outputDirectory.toFile()));
+
+        assertFalse(Files.exists(outsidePath));
+        verify(response, never()).readEntity(java.io.InputStream.class);
+    }
+
+    private Response getResponse(final String contentDisposition) {
+        final Response response = mock(Response.class);
+        
when(response.getHeaderString("Content-Disposition")).thenReturn(contentDisposition);
+        when(response.readEntity(java.io.InputStream.class)).thenReturn(new 
ByteArrayInputStream(CONTENT));
+        return response;
+    }
+}

Reply via email to