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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new b2e39a7c0951 CAMEL-26066: camel-jsch - the scp producer refuses a file 
name with a line feed or a NUL character (#29285)
b2e39a7c0951 is described below

commit b2e39a7c0951b3beb532e4e7b20e6621482d995f
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Nov 6 08:20:53 2026 +0100

    CAMEL-26066: camel-jsch - the scp producer refuses a file name with a line 
feed or a NUL character (#29285)
    
    The scp producer now refuses a file name (including directory segments, 
done-file and temp-file names) that contains a line feed or a NUL character, 
before any scp command is started.
    
    AI-generated with Claude Code on behalf of Federico Mariani.
    
    Co-Authored-By: Claude <[email protected]>
---
 .../apache/camel/catalog/docs/scp-component.adoc   |   4 +
 .../camel-jsch/src/main/docs/scp-component.adoc    |   4 +
 .../apache/camel/component/scp/ScpOperations.java  |  22 +++
 .../component/scp/ScpFileNameControlCharTest.java  | 154 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc    |  13 ++
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |  13 ++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  13 ++
 7 files changed, 223 insertions(+)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/scp-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/scp-component.adoc
index 5ce99b38b295..513db7edcbb9 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/scp-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/scp-component.adoc
@@ -74,6 +74,10 @@ The directory of the endpoint (`/upload` above) must exist 
on the remote host, a
 when it does not exist, each file is written to a file named like the 
directory, and the last one wins.
 The sub directories in the `CamelFileName` header (such as `mysub/hello.txt`) 
are created in that directory.
 
+scp sends the name of each sub directory and of the file on a protocol line 
that ends with a line feed, so a file
+name with a line feed or a NUL character is refused with a 
`GenericFileOperationFailedException` before anything is
+sent to the remote host. Any other character, such as a carriage return or a 
tab, is sent as is.
+
 Without a known hosts file (`useUserKnownHostsFile=false` and no 
`knownHostsFile`), no host is known,
 so the option `strictHostKeyChecking` decides: with `yes` the connection 
fails, with `no` (the default)
 the host key is not checked.
diff --git a/components/camel-jsch/src/main/docs/scp-component.adoc 
b/components/camel-jsch/src/main/docs/scp-component.adoc
index 5ce99b38b295..513db7edcbb9 100644
--- a/components/camel-jsch/src/main/docs/scp-component.adoc
+++ b/components/camel-jsch/src/main/docs/scp-component.adoc
@@ -74,6 +74,10 @@ The directory of the endpoint (`/upload` above) must exist 
on the remote host, a
 when it does not exist, each file is written to a file named like the 
directory, and the last one wins.
 The sub directories in the `CamelFileName` header (such as `mysub/hello.txt`) 
are created in that directory.
 
+scp sends the name of each sub directory and of the file on a protocol line 
that ends with a line feed, so a file
+name with a line feed or a NUL character is refused with a 
`GenericFileOperationFailedException` before anything is
+sent to the remote host. Any other character, such as a carriage return or a 
tab, is sent as is.
+
 Without a known hosts file (`useUserKnownHostsFile=false` and no 
`knownHostsFile`), no host is known,
 so the option `strictHostKeyChecking` decides: with `yes` the connection 
fails, with `no` (the default)
 the host key is not checked.
diff --git 
a/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
 
b/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
index f853b13e299e..d3f34b275329 100644
--- 
a/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
+++ 
b/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
@@ -103,6 +103,8 @@ public class ScpOperations implements 
RemoteFileOperations<ScpFile> {
     @Override
     public boolean storeFile(String name, Exchange exchange, long size) throws 
GenericFileOperationFailedException {
         ObjectHelper.notNull(session, "session");
+        // before the channel is opened, so nothing is sent for a name that is 
refused
+        checkFileName(name);
         ScpConfiguration cfg = endpoint.getConfiguration();
 
         int timeout = cfg.getConnectTimeout();
@@ -162,6 +164,7 @@ public class ScpOperations implements 
RemoteFileOperations<ScpFile> {
 
     @Override
     public boolean storeFileDirectly(String name, String payload) throws 
GenericFileOperationFailedException {
+        checkFileName(name);
         ByteArrayInputStream bis = null;
         try {
             bis = new ByteArrayInputStream(payload.getBytes());
@@ -504,6 +507,25 @@ public class ScpOperations implements 
RemoteFileOperations<ScpFile> {
         }
     }
 
+    /**
+     * Refuses a file name with a line feed or a NUL character. The scp 
protocol sends the name of each directory and of
+     * the file on a line that ends with a line feed, so a line feed in the 
name would end the protocol line early, and
+     * the remote scp would read the rest of the name as the next line. The 
remote scp refuses a NUL character in a
+     * name, or cuts the name short there. Any other character, such as a 
carriage return or a tab, is sent as is. The
+     * message gives the character and its index, not the name.
+     */
+    private static void checkFileName(String name) {
+        for (int i = 0; i < name.length(); i++) {
+            char ch = name.charAt(i);
+            if (ch == '\n' || ch == '\0') {
+                throw new GenericFileOperationFailedException(
+                        String.format("Cannot write file: the file name has 
the character U+%04X at index %d, and a line feed"
+                                      + " or a NUL character cannot be sent in 
an scp file name",
+                                (int) ch, i));
+            }
+        }
+    }
+
     private void writeAck(OutputStream os) throws IOException {
         os.write(0);
         os.flush();
diff --git 
a/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpFileNameControlCharTest.java
 
b/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpFileNameControlCharTest.java
new file mode 100644
index 000000000000..8756e2383dc8
--- /dev/null
+++ 
b/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpFileNameControlCharTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.scp;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Stream;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.file.GenericFileOperationFailedException;
+import org.apache.camel.component.file.remote.RemoteFileOperations;
+import org.apache.sshd.scp.server.ScpCommandFactory;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.command.Command;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * The scp producer sends the name of each directory and of the file on a 
protocol line that ends with a line feed, so
+ * it refuses a file name with a line feed or a NUL character before it starts 
the scp command on the server. Any other
+ * character, such as a carriage return or a tab, is sent as is.
+ * <p/>
+ * The server directory is checked directly, so unlike {@link 
ScpSimpleProduceTest} there is no route that consumes and
+ * deletes the files written there.
+ */
+public class ScpFileNameControlCharTest extends ScpServerTestSupport {
+
+    private final List<String> scpCommands = new CopyOnWriteArrayList<>();
+
+    public ScpFileNameControlCharTest() {
+        // records the scp commands the server starts
+        serverConfigurer = sshd -> sshd.setCommandFactory(new 
ScpCommandFactory() {
+            @Override
+            public Command createCommand(ChannelSession channel, String 
command) throws IOException {
+                scpCommands.add(command);
+                return super.createCommand(channel, command);
+            }
+        });
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "a\nb.txt", "a\u0000b.txt", "hello.txt\n",
+            "a\nb/file.txt", "a\u0000b/file.txt", "mysub/a\nb/file.txt" })
+    public void testFileNameWithControlCharIsRefused(String name) throws 
Exception {
+        assumeTrue(isSetupComplete());
+
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(getProducerUri(), "Hello 
World", Exchange.FILE_NAME, name));
+
+        assertRefused(e, name);
+    }
+
+    @Test
+    public void testFileNameOptionWithControlCharIsRefused() throws Exception {
+        assumeTrue(isSetupComplete());
+
+        String uri = getProducerUri() + "&fileName=${header.target}";
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(uri, "Hello World", "target", 
"a\nb.txt"));
+
+        assertRefused(e, "a\nb.txt");
+    }
+
+    @Test
+    public void testStoreFileDirectlyWithControlCharIsRefused() {
+        ScpEndpoint endpoint = context.getEndpoint(getProducerUri(), 
ScpEndpoint.class);
+        RemoteFileOperations<ScpFile> operations = 
endpoint.createRemoteFileOperations();
+
+        GenericFileOperationFailedException e = 
assertThrows(GenericFileOperationFailedException.class,
+                () -> operations.storeFileDirectly("a\nb.txt.md5", "abc"));
+
+        assertTrue(e.getMessage().contains("character U+000A at index 1"), 
e.getMessage());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "a\rb.txt", "hello.txt\r", "my\rsub/a\rb.txt", "a\tb.txt", 
"my\tsub/a\tb.txt", "a\u001Bb.txt",
+            "a\u0085b.txt" })
+    @DisabledOnOs(value = OS.WINDOWS,
+                  disabledReason = "a Windows file name cannot hold a carriage 
return, a tab or an escape character")
+    public void testFileNameWithOtherControlCharIsWritten(String name) throws 
Exception {
+        assumeTrue(isSetupComplete());
+
+        template.sendBodyAndHeader(getProducerUri(), "Hello World", 
Exchange.FILE_NAME, name);
+
+        assertEquals(1, scpCommands.size(), "scp commands started on the 
server: " + scpCommands);
+        assertEquals("Hello World", Files.readString(Paths.get(getScpPath(), 
name)));
+    }
+
+    @Test
+    public void testNestedFileName() throws Exception {
+        assumeTrue(isSetupComplete());
+
+        template.sendBodyAndHeader(getProducerUri(), "Hello World", 
Exchange.FILE_NAME, "mysub/mysubsub/hello.txt");
+
+        assertEquals(1, scpCommands.size(), "scp commands started on the 
server: " + scpCommands);
+        assertEquals(List.of("mysub", "mysub/mysubsub", 
"mysub/mysubsub/hello.txt"), listServerFiles());
+        assertEquals("Hello World", Files.readString(Paths.get(getScpPath(), 
"mysub/mysubsub/hello.txt")));
+    }
+
+    private void assertRefused(CamelExecutionException e, String name) throws 
IOException {
+        GenericFileOperationFailedException cause
+                = assertInstanceOf(GenericFileOperationFailedException.class, 
e.getCause());
+        assertTrue(cause.getMessage().contains("a line feed or a NUL 
character"), cause.getMessage());
+        assertFalse(cause.getMessage().contains(name), "the message does not 
give the name");
+        // nothing is sent: the scp command is not started on the server, and 
no file or directory is created
+        assertEquals(List.of(), scpCommands, "scp commands started on the 
server");
+        assertEquals(List.of(), listServerFiles(), "files on the server");
+    }
+
+    private String getProducerUri() {
+        return getScpUri() + "?username=admin&password=admin&knownHostsFile=" 
+ getKnownHostsFile();
+    }
+
+    private List<String> listServerFiles() throws IOException {
+        Path root = Paths.get(getScpPath());
+        try (Stream<Path> files = Files.walk(root)) {
+            return files.filter(p -> !p.equals(root))
+                    .map(p -> root.relativize(p).toString().replace('\\', '/'))
+                    .sorted()
+                    .toList();
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index 8f1d382ee8da..07b80d388019 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
@@ -735,6 +735,19 @@ A route that relied on a Simple expression in a header tag 
or attribute should u
 option instead (for example `tags.customer=${header.customerId}`), or compute 
the value in the route before
 setting the header.
 
+=== camel-jsch - the scp producer refuses a file name with a line feed or a 
NUL character
+
+The `scp` producer now refuses a file name with a line feed or a NUL 
character, in the file name or in one of its
+directories, with a `GenericFileOperationFailedException`. It does so before 
it starts scp on the remote host, so
+nothing is written there. Any other character, such as a carriage return or a 
tab, is sent as before.
+
+scp sends each name on a protocol line that ends with a line feed. Before, a 
line feed in the file name ended that
+line early: the remote scp wrote a file named after the part of the name 
before the line feed, with the rest of the
+name at the start of its content, and the exchange did not fail. A line feed 
in a directory failed the exchange, but
+left a directory named after the part before it. A NUL character failed the 
exchange on the remote host, or, with an
+OpenSSH server before 10.6, cut the name short there. Remove or replace these 
characters in the `CamelFileName` header
+or in the `fileName` option before the message reaches the producer.
+
 == Upgrading from 4.18.4 to 4.18.5
 
 === camel-azure-eventhubs
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 5e943dbb99c4..4c8a35dc8da5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -598,6 +598,19 @@ A route that relied on a Simple expression in a header tag 
or attribute should u
 option instead (for example `tags.customer=${header.customerId}`), or compute 
the value in the route before
 setting the header.
 
+=== camel-jsch - the scp producer refuses a file name with a line feed or a 
NUL character
+
+The `scp` producer now refuses a file name with a line feed or a NUL 
character, in the file name or in one of its
+directories, with a `GenericFileOperationFailedException`. It does so before 
it starts scp on the remote host, so
+nothing is written there. Any other character, such as a carriage return or a 
tab, is sent as before.
+
+scp sends each name on a protocol line that ends with a line feed. Before, a 
line feed in the file name ended that
+line early: the remote scp wrote a file named after the part of the name 
before the line feed, with the rest of the
+name at the start of its content, and the exchange did not fail. A line feed 
in a directory failed the exchange, but
+left a directory named after the part before it. A NUL character failed the 
exchange on the remote host, or, with an
+OpenSSH server before 10.6, cut the name short there. Remove or replace these 
characters in the `CamelFileName` header
+or in the `fileName` option before the message reaches the producer.
+
 == Upgrading from 4.22.1 to 4.22.2
 
 === camel-lra - only an LRA hosted by the configured coordinator is joined
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 55dd86910893..b596238577f4 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1478,6 +1478,19 @@ azure-files upload the bytes of the body without the 
`charset` option, while the
 `charset` with `checksumFileAlgorithm` there. FTP uploads in ASCII mode unless 
`binary=true`, and the server may then
 change the line ends of the stored file, so its checksum can differ from the 
checksum of the uploaded bytes.
 
+=== camel-jsch - the scp producer refuses a file name with a line feed or a 
NUL character
+
+The `scp` producer now refuses a file name with a line feed or a NUL 
character, in the file name or in one of its
+directories, with a `GenericFileOperationFailedException`. It does so before 
it starts scp on the remote host, so
+nothing is written there. Any other character, such as a carriage return or a 
tab, is sent as before.
+
+scp sends each name on a protocol line that ends with a line feed. Before, a 
line feed in the file name ended that
+line early: the remote scp wrote a file named after the part of the name 
before the line feed, with the rest of the
+name at the start of its content, and the exchange did not fail. A line feed 
in a directory failed the exchange, but
+left a directory named after the part before it. A NUL character failed the 
exchange on the remote host, or, with an
+OpenSSH server before 10.6, cut the name short there. Remove or replace these 
characters in the `CamelFileName` header
+or in the `fileName` option before the message reaches the producer.
+
 === camel-file - a done file with a fixed name is kept for the files a poll 
left
 
 A done file with a fixed name (such as `doneFileName=ready`) is no longer 
deleted while a poll left files of its

Reply via email to