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