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.

Reply via email to