This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch camel-4.14.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.14.x by this push:
new 4b9b4ade1514 [backport camel-4.14.x] CAMEL-24279: camel-google-storage
- contain downloadFileName downloads within the configured directory (#25181)
4b9b4ade1514 is described below
commit 4b9b4ade15148e1512b39f302075b36c7a092e86
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Jul 28 14:12:42 2026 +0200
[backport camel-4.14.x] CAMEL-24279: camel-google-storage - contain
downloadFileName downloads within the configured directory (#25181)
CAMEL-24279: camel-google-storage - contain downloadFileName downloads
within the configured directory
The consumer builds the local download destination by appending the remote
object name to the configured downloadFileName directory via the
${file:name}
token, which returns the name unchanged. A remote object name containing
path
segments could therefore resolve outside the configured directory.
Verify the resolved destination stays within downloadFileName, rejecting
anything that resolves outside it. Object names keep being mapped to
sub-directories of the download directory, since Google Cloud Storage object
names commonly use / as a pseudo-directory separator and stripping the path
component would flatten nested names and make distinct objects collide.
A downloadFileName that already contains an expression is built by the route
author and is deliberately left outside the check.
This aligns the component with the containment already applied to the
file/FTP/SFTP/SMB consumers (CAMEL-23765, CAMEL-23868) and to the Azure
Storage
Blob/DataLake download paths (CAMEL-23942).
(cherry picked from commit 331dc67f8c502cd1d5fcc3abd5669315db35bede)
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../camel-google/camel-google-storage/pom.xml | 5 +
.../google/storage/GoogleCloudStorageConsumer.java | 9 +-
.../storage/GoogleCloudStorageFileNameHelper.java | 56 ++++++++++++
...GoogleCloudStorageConsumerDownloadPathTest.java | 101 +++++++++++++++++++++
.../GoogleCloudStorageFileNameHelperTest.java | 83 +++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_14.adoc | 14 +++
6 files changed, 267 insertions(+), 1 deletion(-)
diff --git a/components/camel-google/camel-google-storage/pom.xml
b/components/camel-google/camel-google-storage/pom.xml
index 34e11030a71a..de7921183665 100644
--- a/components/camel-google/camel-google-storage/pom.xml
+++ b/components/camel-google/camel-google-storage/pom.xml
@@ -70,5 +70,10 @@
<scope>test</scope>
<classifier>tests</classifier>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git
a/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
b/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
index d2e26f77be75..35b867c7391a 100644
---
a/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
+++
b/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
@@ -353,7 +353,11 @@ public class GoogleCloudStorageConsumer extends
ScheduledBatchPollingConsumer {
exchange.getMessage().setHeader(GoogleCloudStorageConstants.FILE_NAME,
blogName);
String eval = downloadFileName;
- if (!downloadFileName.contains("$")) {
+ // when the configured downloadFileName is a plain directory, the
remote object name is appended to it. That
+ // name is untrusted input, so the resolved path has to be confined to
the configured directory. When the
+ // configuration already contains an expression the local path is
built by the route author, who is trusted.
+ boolean confineToDirectory = !downloadFileName.contains("$");
+ if (confineToDirectory) {
eval = downloadFileName + "/${file:name}";
}
Expression exp = language.createExpression(eval);
@@ -363,6 +367,9 @@ public class GoogleCloudStorageConsumer extends
ScheduledBatchPollingConsumer {
if (exchange.getException() != null) {
throw
RuntimeCamelException.wrapRuntimeCamelException(exchange.getException());
}
+ if (confineToDirectory && result != null) {
+
GoogleCloudStorageFileNameHelper.assertWithinDirectory(downloadFileName,
result, blogName);
+ }
return result;
}
diff --git
a/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageFileNameHelper.java
b/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageFileNameHelper.java
new file mode 100644
index 000000000000..59ca3a2b6e2e
--- /dev/null
+++
b/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageFileNameHelper.java
@@ -0,0 +1,56 @@
+/*
+ * 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.camel.component.google.storage;
+
+import java.io.File;
+import java.nio.file.Path;
+
+/**
+ * Utility methods for safely handling local file paths derived from remote
Google Cloud Storage object names.
+ */
+final class GoogleCloudStorageFileNameHelper {
+
+ private GoogleCloudStorageFileNameHelper() {
+ }
+
+ /**
+ * Verifies that a local download path built from a remote object name
stays within the configured download
+ * directory. A remote object name is influenced by whoever writes to the
bucket and may contain path segments that
+ * would otherwise resolve to a location outside the download directory.
+ * <p>
+ * Object names are not stripped of their path component on purpose:
Google Cloud Storage object names commonly use
+ * {@code /} as a pseudo-directory separator, so stripping would flatten
nested names and could make distinct
+ * objects collide on the same local file.
+ *
+ * @param downloadDirectory the configured local directory the
download must stay within
+ * @param resolvedPath the resolved local path built from
{@code downloadDirectory} and the object name
+ * @param objectName the remote object name used to build
the local path, for error reporting
+ * @throws IllegalArgumentException if the resolved path is located
outside {@code downloadDirectory}
+ */
+ static void assertWithinDirectory(String downloadDirectory, String
resolvedPath, String objectName) {
+ // normalize lexically (removes ./ and ../ segments) and compare on
path-segment boundaries so a sibling
+ // directory whose name merely extends downloadDirectory is not
considered contained
+ final Path normalizedDir = new
File(downloadDirectory).toPath().normalize();
+ final Path normalizedTarget = new
File(resolvedPath).toPath().normalize();
+ if (!normalizedTarget.startsWith(normalizedDir)) {
+ throw new IllegalArgumentException(
+ "Cannot download to file '" + objectName
+ + "' as it resolves outside the
configured downloadFileName directory: "
+ + downloadDirectory);
+ }
+ }
+}
diff --git
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerDownloadPathTest.java
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerDownloadPathTest.java
new file mode 100644
index 000000000000..310ab9715d6e
--- /dev/null
+++
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerDownloadPathTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.camel.component.google.storage;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import
org.apache.camel.component.google.storage.localstorage.LocalStorageHelper;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Verifies that the local download path the consumer builds from a remote
object name stays within the configured
+ * {@code downloadFileName} directory.
+ */
+class GoogleCloudStorageConsumerDownloadPathTest extends CamelTestSupport {
+
+ private static final String DOWNLOAD_DIR = "target/gcs-download-path";
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ GoogleCloudStorageComponent component =
context.getComponent("google-storage", GoogleCloudStorageComponent.class);
+
component.getConfiguration().setStorageClient(LocalStorageHelper.getOptions().getService());
+ return context;
+ }
+
+ private GoogleCloudStorageConsumer createConsumer(String downloadFileName)
throws Exception {
+ GoogleCloudStorageEndpoint endpoint = context.getEndpoint(
+
"google-storage://myCamelBucket?autoCreateBucket=true&downloadFileName=" +
downloadFileName,
+ GoogleCloudStorageEndpoint.class);
+ return (GoogleCloudStorageConsumer) endpoint.createConsumer(exchange
-> {
+ });
+ }
+
+ @Test
+ void plainObjectNameResolvesInsideDownloadDirectory() throws Exception {
+ GoogleCloudStorageConsumer consumer = createConsumer(DOWNLOAD_DIR);
+ Exchange exchange = new DefaultExchange(context);
+
+ assertThat(consumer.evaluateFileExpression(exchange, DOWNLOAD_DIR,
"file.txt"))
+ .isEqualTo(DOWNLOAD_DIR + "/file.txt");
+ }
+
+ @Test
+ void nestedObjectNameResolvesInsideDownloadDirectory() throws Exception {
+ GoogleCloudStorageConsumer consumer = createConsumer(DOWNLOAD_DIR);
+ Exchange exchange = new DefaultExchange(context);
+
+ assertThat(consumer.evaluateFileExpression(exchange, DOWNLOAD_DIR,
"nested/file.txt"))
+ .isEqualTo(DOWNLOAD_DIR + "/nested/file.txt");
+ }
+
+ @Test
+ void objectNameWithParentSegmentIsRejected() throws Exception {
+ GoogleCloudStorageConsumer consumer = createConsumer(DOWNLOAD_DIR);
+ Exchange exchange = new DefaultExchange(context);
+
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> consumer.evaluateFileExpression(exchange,
DOWNLOAD_DIR, "../escape.txt"))
+ .withMessageContaining("../escape.txt");
+ }
+
+ @Test
+ void objectNameWithParentSegmentNestedInTheKeyIsRejected() throws
Exception {
+ GoogleCloudStorageConsumer consumer = createConsumer(DOWNLOAD_DIR);
+ Exchange exchange = new DefaultExchange(context);
+
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> consumer.evaluateFileExpression(exchange,
DOWNLOAD_DIR, "nested/../../escape.txt"));
+ }
+
+ @Test
+ void routeAuthorSuppliedExpressionIsNotConfined() throws Exception {
+ // a downloadFileName that already contains an expression is built by
the route author, who is trusted, so it is
+ // evaluated as configured and deliberately left outside the
containment check
+ String expression = "target/${file:name}";
+ GoogleCloudStorageConsumer consumer = createConsumer(DOWNLOAD_DIR);
+ Exchange exchange = new DefaultExchange(context);
+
+ assertThat(consumer.evaluateFileExpression(exchange, expression,
"../escape.txt"))
+ .isEqualTo("target/../escape.txt");
+ }
+}
diff --git
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageFileNameHelperTest.java
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageFileNameHelperTest.java
new file mode 100644
index 000000000000..d84c9e42468d
--- /dev/null
+++
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageFileNameHelperTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.camel.component.google.storage;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+class GoogleCloudStorageFileNameHelperTest {
+
+ private static final String DIR = "target/gcs-download";
+
+ @Test
+ void plainObjectNameIsAccepted() {
+ assertThatCode(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR, DIR + "/file.txt",
"file.txt"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void nestedObjectNameIsAccepted() {
+ // GCS object names commonly use / as a pseudo-directory separator, so
nesting must keep working
+ assertThatCode(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR, DIR + "/a/b/c.txt",
"a/b/c.txt"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void objectNameNormalizingBackInsideIsAccepted() {
+ assertThatCode(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR, DIR +
"/a/../b.txt", "a/../b.txt"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void downloadDirectoryItselfIsAccepted() {
+ assertThatCode(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR, DIR, ""))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void parentDirectorySegmentIsRejected() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR, DIR +
"/../escape.txt",
+ "../escape.txt"))
+ .withMessageContaining("../escape.txt")
+ .withMessageContaining(DIR);
+ }
+
+ @Test
+ void repeatedParentDirectorySegmentsAreRejected() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR,
+ DIR + "/../../../etc/pwn.txt",
"../../../etc/pwn.txt"));
+ }
+
+ @Test
+ void parentDirectorySegmentNestedInsideObjectNameIsRejected() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR,
+ DIR + "/a/../../escape.txt", "a/../../escape.txt"));
+ }
+
+ @Test
+ void siblingDirectoryMerelyExtendingTheNameIsRejected() {
+ // guards against a plain String.startsWith check, which would wrongly
accept this
+ assertThatIllegalArgumentException()
+ .isThrownBy(() ->
GoogleCloudStorageFileNameHelper.assertWithinDirectory(DIR,
+ DIR + "-evil/file.txt",
"../gcs-download-evil/file.txt"));
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_14.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_14.adoc
index 5a9c1b64a06f..146640138815 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_14.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_14.adoc
@@ -1343,3 +1343,17 @@ by the file-based consumers.
Ordinary object names are unaffected. A name that resolves outside `fileDir`
is now rejected with an
`IllegalArgumentException`.
+
+=== camel-google-storage - downloads are confined to the configured directory
+
+When the consumer is configured with `downloadFileName` pointing at a
directory, the
+remote object name is appended to that directory to build the local file to
write.
+The resolved path is now verified to stay within the configured directory, and
an
+`IllegalArgumentException` is thrown if it does not.
+
+Object names are still allowed to contain `/` and are mapped to
sub-directories of the
+download directory as before, so nested object names keep working unchanged.
Only names
+that resolve outside the configured directory are rejected.
+
+If `downloadFileName` is configured with an expression (i.e. it contains `$`),
the local
+path is built by that expression as before and is not subject to this check.