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

oscerd 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 4848699ace04 CAMEL-24433: camel-mail - sanitise the attachment name on 
unmarshal and quote it on marshal (#26661)
4848699ace04 is described below

commit 4848699ace04af38b6568d2fda3a766c9f2b916f
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Sep 24 09:39:32 2026 +0200

    CAMEL-24433: camel-mail - sanitise the attachment name on unmarshal and 
quote it on marshal (#26661)
    
    * CAMEL-24433: camel-mail - sanitise the attachment name on unmarshal and 
quote it on marshal
    
    Both ends of the same sender-chosen value.
    
    On unmarshal, MimeMultipartDataFormat.getAttachmentKey() took the file name 
from
    the part, decoded it and used it as-is to identify the attachment.
    MailBinding.extractAndNormalizeFileName() already strips control characters 
and
    reduces the name to a leaf with FileUtil.stripPath; the data format did 
neither,
    so a name carrying path components survived intact. It now applies the same
    normalisation.
    
    On marshal, the outgoing Content-Type header was built by concatenation:
    
        String value = contentType + "; name=" + attachmentFilename;
    
    The extraction side is well sanitised, but a legal file name may still 
contain a
    semicolon or a double quote, and those are exactly the characters that 
matter in
    a MIME parameter. The header is now built with ContentType.setParameter, so
    ParameterList quotes and escapes the value when it needs to.
    
    Both are covered against the previous code:
    
    * the unmarshalled attachment was named "../../evil.sh" and is now "evil.sh"
    * the outgoing header was "image/jpeg; name=report.jpeg; 
boundary=--injected",
      where getParameter("boundary") returned "--injected" - the file name had
      introduced a parameter of its own. It is now
      "image/jpeg; name=\"report.jpeg; boundary=--injected\"" with no extra
      parameter.
    
    The existing MailContentTypeResolverTest asserts the exact header string
    "image/jpeg; name=logo.jpeg" and still passes: ParameterList only quotes 
values
    containing tspecials, so ordinary names are formatted as before.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * CAMEL-24433: camel-mail - scope attachment-name stripPath to the file 
name and document the ContentType parse change
    
    Addresses review feedback on #26661:
    - getAttachmentKey applied FileUtil.stripPath to every key, including one 
taken
      from Content-ID. A Content-ID local part may legally contain '/', so the 
key is
      now reduced to a leaf name only when it comes from the sender-chosen file 
name;
      Content-ID and generated ids are left as-is. Added a test.
    - documented in the 4.23 upgrade guide that a custom ContentTypeResolver
      returning an unparsable content type now fails fast with a ParseException,
      where before the raw string was written into the header (the parse also 
closes
      a header-injection vector via the attachment file name).
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
    
    ---------
    
    Signed-off-by: Andrea Cosentino <[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../apache/camel/component/mail/MailBinding.java   |   9 +-
 .../mime/multipart/MimeMultipartDataFormat.java    |  10 +-
 .../mail/MailAttachmentFileNameQuotingTest.java    | 116 +++++++++++++++++++++
 .../multipart/MimeMultipartDataFormatTest.java     |  25 +++++
 .../resources/multipart-contentid-with-slash.txt   |  13 +++
 .../test/resources/multipart-traversal-name.txt    |  21 ++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   7 ++
 7 files changed, 198 insertions(+), 3 deletions(-)

diff --git 
a/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailBinding.java
 
b/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailBinding.java
index 708f6427a494..ddc61548b6a1 100644
--- 
a/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailBinding.java
+++ 
b/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailBinding.java
@@ -39,6 +39,7 @@ import jakarta.mail.MessagingException;
 import jakarta.mail.Multipart;
 import jakarta.mail.Part;
 import jakarta.mail.internet.AddressException;
+import jakarta.mail.internet.ContentType;
 import jakarta.mail.internet.InternetAddress;
 import jakarta.mail.internet.MimeBodyPart;
 import jakarta.mail.internet.MimeMessage;
@@ -748,8 +749,12 @@ public class MailBinding {
                         LOG.trace("Attachment #{}: Using content type 
resolver: {} resolved content type as: {}", i,
                                 contentTypeResolver, contentType);
                         if (contentType != null) {
-                            String value = contentType + "; name=" + 
attachmentFilename;
-                            messageBodyPart.setHeader("Content-Type", value);
+                            // The file name comes from the message being 
relayed, so it must go out as a
+                            // parameter value rather than be concatenated 
into the header. ParameterList
+                            // quotes and escapes anything that would 
otherwise change the header's structure.
+                            ContentType parsed = new ContentType(contentType);
+                            parsed.setParameter("name", attachmentFilename);
+                            messageBodyPart.setHeader("Content-Type", 
parsed.toString());
                             LOG.trace("Attachment #{}: ContentType: {}", i, 
messageBodyPart.getContentType());
                         }
                     }
diff --git 
a/components/camel-mail/src/main/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormat.java
 
b/components/camel-mail/src/main/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormat.java
index edd685a537b1..f64bdfa545bb 100644
--- 
a/components/camel-mail/src/main/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormat.java
+++ 
b/components/camel-mail/src/main/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormat.java
@@ -56,6 +56,7 @@ import org.apache.camel.spi.annotations.Dataformat;
 import org.apache.camel.support.DefaultDataFormat;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.MessageHelper;
+import org.apache.camel.util.FileUtil;
 import org.apache.camel.util.IOHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -405,6 +406,7 @@ public class MimeMultipartDataFormat extends 
DefaultDataFormat {
     private String getAttachmentKey(BodyPart bp) throws MessagingException, 
UnsupportedEncodingException {
         // use the filename as key for the map
         String key = bp.getFileName();
+        boolean fromFileName = key != null;
         // if there is no file name we use the Content-ID header
         if (key == null && bp instanceof MimeBodyPart mimeBodyPart) {
             key = mimeBodyPart.getContentID();
@@ -417,7 +419,13 @@ public class MimeMultipartDataFormat extends 
DefaultDataFormat {
         if (key == null) {
             key = UUID.randomUUID() + "@camel.apache.org";
         }
-        return MimeUtility.decodeText(key);
+        key = MimeUtility.decodeText(key);
+        key = key.replaceAll("[\n\r\t]", "_");
+        // Only a file name is sender-chosen path data. Reduce it to a leaf 
name the same way
+        // MailBinding.extractAndNormalizeFileName does, so it cannot carry 
path components before it identifies the
+        // attachment. A Content-ID local part may legally contain '/' (RFC 
5322 atext) and a generated id never
+        // carries a path, so those are left as-is.
+        return fromFileName ? FileUtil.stripPath(key) : key;
     }
 
     @Override
diff --git 
a/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailAttachmentFileNameQuotingTest.java
 
b/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailAttachmentFileNameQuotingTest.java
new file mode 100644
index 000000000000..e336315d8a59
--- /dev/null
+++ 
b/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailAttachmentFileNameQuotingTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.mail;
+
+import java.util.Map;
+
+import jakarta.activation.DataHandler;
+import jakarta.activation.FileDataSource;
+import jakarta.mail.internet.ContentType;
+
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.Producer;
+import org.apache.camel.attachment.AttachmentMessage;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mail.Mailbox.MailboxUser;
+import org.apache.camel.component.mail.Mailbox.Protocol;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Unit test for Camel attachments and Mail attachments.
+ */
+public class MailAttachmentFileNameQuotingTest extends CamelTestSupport {
+    private static final String HOSTILE_NAME = "report.jpeg; 
boundary=--injected";
+
+    private static final MailboxUser james = 
Mailbox.getOrCreateUser("MailAttachmentFileNameQuotingTest-james", "secret");
+
+    @Test
+    public void hostileAttachmentNameIsQuotedInTheContentTypeHeader() throws 
Exception {
+        // clear mailbox
+        Mailbox.clearAll();
+
+        // create an exchange with a normal body and attachment to be produced 
as email
+        Endpoint endpoint = 
context.getEndpoint(james.uriPrefix(Protocol.smtp));
+
+        // create the exchange with the mail message that is multipart with a 
file and a Hello World text/plain message.
+        Exchange exchange = endpoint.createExchange();
+        AttachmentMessage in = exchange.getIn(AttachmentMessage.class);
+        in.setBody("Hello World");
+        in.addAttachment(HOSTILE_NAME, new DataHandler(new 
FileDataSource("src/test/data/logo.jpeg")));
+
+        // create a producer that can produce the exchange (= send the mail)
+        Producer producer = endpoint.createProducer();
+        // start the producer
+        producer.start();
+        // and let it go (processes the exchange by sending the email)
+        producer.process(exchange);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(1);
+        mock.assertIsSatisfied();
+        Exchange out = mock.assertExchangeReceived(0);
+
+        // plain text
+        assertEquals("Hello World", out.getIn().getBody(String.class));
+
+        // attachment
+        Map<String, DataHandler> attachments = 
out.getIn(AttachmentMessage.class).getAttachments();
+        assertNotNull(attachments, "Should have attachments");
+        assertEquals(1, attachments.size());
+
+        DataHandler handler = attachments.values().iterator().next();
+        assertNotNull(handler, "The attachment should be there");
+
+        // The file name is relayed from the incoming message, so it must be 
emitted as a quoted
+        // parameter value. Concatenating it lets a name containing a 
semicolon add parameters of its
+        // own to the header - here a second boundary declaration.
+        // Parse it rather than string-match: the hostile text appearing 
anywhere in the header is fine,
+        // what matters is whether it is inside the quoted name or has become 
a parameter of its own.
+        ContentType contentType = new ContentType(handler.getContentType());
+        assertNull(contentType.getParameter("boundary"),
+                "Attachment name must not introduce a parameter: " + 
handler.getContentType());
+        assertEquals(HOSTILE_NAME, contentType.getParameter("name"),
+                "The whole name must survive as a single parameter value");
+
+        producer.stop();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            public void configure() {
+                MailComponent mail = getContext().getComponent("smtp", 
MailComponent.class);
+                mail.setContentTypeResolver(new ContentTypeResolver() {
+                    public String resolveContentType(String fileName) {
+                        return "image/jpeg";
+                    }
+                });
+
+                from(james.uriPrefix(Protocol.pop3) + 
"&initialDelay=100&delay=100")
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        };
+    }
+}
diff --git 
a/components/camel-mail/src/test/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormatTest.java
 
b/components/camel-mail/src/test/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormatTest.java
index d81cebb3fd6c..72500ed71172 100644
--- 
a/components/camel-mail/src/test/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormatTest.java
+++ 
b/components/camel-mail/src/test/java/org/apache/camel/dataformat/mime/multipart/MimeMultipartDataFormatTest.java
@@ -428,6 +428,31 @@ public class MimeMultipartDataFormatTest extends 
CamelTestSupport {
         assertNull(out.getMessage().getHeader("CAMELBaz"));
     }
 
+    @Test
+    void unmarshalAttachmentNameIsReducedToALeafName() {
+        // the attachment file name is chosen by the sender of the message 
being unmarshalled
+        in.setBody(new 
File("src/test/resources/multipart-traversal-name.txt"));
+        Exchange out = template.send("direct:unmarshalonlyinlineheaders", 
exchange);
+
+        AttachmentMessage am = out.getMessage(AttachmentMessage.class);
+        assertThat(am.getAttachmentNames())
+                .contains("evil.sh")
+                .doesNotContain("../../evil.sh");
+    }
+
+    @Test
+    void unmarshalContentIdKeyKeepsSlashesBecauseItIsNotAPath() {
+        // a Content-ID local part may legally contain '/' (RFC 5322 atext); 
it is an identifier, not a sender-chosen
+        // file name, so it must not be reduced to a leaf name the way a file 
name is
+        in.setBody(new 
File("src/test/resources/multipart-contentid-with-slash.txt"));
+        Exchange out = template.send("direct:unmarshalonlyinlineheaders", 
exchange);
+
+        AttachmentMessage am = out.getMessage(AttachmentMessage.class);
+        assertThat(am.getAttachmentNames())
+                .contains("a/[email protected]")
+                .doesNotContain("[email protected]");
+    }
+
     @Test
     void unmarshalInlineHeadersFiltersMailSessionPropertyHeaders() {
         // MailHeaderFilterStrategy adds the mail.smtp. / mail.smtps. prefixes 
to the inbound filter
diff --git 
a/components/camel-mail/src/test/resources/multipart-contentid-with-slash.txt 
b/components/camel-mail/src/test/resources/multipart-contentid-with-slash.txt
new file mode 100644
index 000000000000..b8def3a96ebc
--- /dev/null
+++ 
b/components/camel-mail/src/test/resources/multipart-contentid-with-slash.txt
@@ -0,0 +1,13 @@
+MIME-Version: 1.0
+Content-Type: Multipart/Related; boundary=example-1
+
+--example-1
+Content-Type: text/plain
+
+the body
+--example-1
+Content-Type: text/plain
+Content-ID: <a/[email protected]>
+
+related part whose Content-ID local part contains a slash
+--example-1--
diff --git 
a/components/camel-mail/src/test/resources/multipart-traversal-name.txt 
b/components/camel-mail/src/test/resources/multipart-traversal-name.txt
new file mode 100644
index 000000000000..8ceaa60f7801
--- /dev/null
+++ b/components/camel-mail/src/test/resources/multipart-traversal-name.txt
@@ -0,0 +1,21 @@
+MIME-Version: 1.0
+Content-Type: Multipart/Related; boundary=example-1; 
start="<[email protected]>"; type="Application/X-FixedRecord"; 
start-info="-o ps"
+
+--example-1
+Content-Type: Application/X-FixedRecord
+Content-ID: <[email protected]>
+
+25
+10
+34
+10
+25
+21
+26
+10
+--example-1
+Content-Type: Application/octet-stream
+Content-Disposition: attachment; filename="../../evil.sh"
+
+payload
+--example-1--
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 1e20dc11dd8e..e4ec6f31e52b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -1094,6 +1094,13 @@ Routes that relied on `+mail.smtp.*+` or 
`+mail.smtps.*+` headers arriving on th
 unmarshalled MIME message must set those values explicitly on the route 
instead. Ordinary
 application headers are unaffected.
 
+`MailBinding` now parses the content type returned by a custom 
`ContentTypeResolver` before adding the
+attachment file name as a quoted parameter, instead of concatenating the file 
name into the header - closing
+a header-injection vector via a crafted file name. As a side effect, a 
`ContentTypeResolver` that returns an
+unparsable content type now fails fast with a `ParseException` at marshal 
time, where before the invalid
+value was written into the `Content-Type` header as-is. Ensure a custom 
`ContentTypeResolver` returns a valid
+MIME type.
+
 === camel-netty - object codecs apply a deserialization filter by default
 
 The `ObjectDecoder` and `DatagramPacketObjectDecoder` codecs (used when a 
route configures Netty

Reply via email to