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