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

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


The following commit(s) were added to refs/heads/main by this push:
     new 6d0d519ea91 strip path components from content-disposition filename 
(#3572)
6d0d519ea91 is described below

commit 6d0d519ea9164eb06aecad1538eab09454338b99
Author: Javid Khan <[email protected]>
AuthorDate: Sun Oct 18 20:32:15 2026 +0530

    strip path components from content-disposition filename (#3572)
    
    * strip path components from content-disposition filename
    
    * sanitise inbound attachment filename at the consumer via 
FileUtils.safeFileName
    
    * neutralise unsafe filename chars and key file parts off the raw filename
    
    replace NUL and ':' with '_' in FileUtils.safeFileName rather than dropping 
an
    otherwise valid name like Meeting 10:30.pdf, and decide whether an 
EntityPart is
    a file from the raw cd.getFilename() so a value such as reports/ or .. 
stays a
    file part while safeFileName only cleans the reported name. adds a jaxrs
    EntityPart test covering the traversal, plain and trailing-separator cases 
and a
    javadoc note on ContentDisposition.getFilename().
    
    * keep an empty filename a form field, not a file part
    
    an empty filename= (what browsers send for an unfilled HTML file input) was
    counting as a file part once the file check moved to the raw value, so it 
went
    down the file branch and lost its form-field name. decide on
    !StringUtils.isEmpty(rawFileName) instead, including the 
createFromAttachment
    check, so filename= stays a form field while reports/ and .. still stay file
    parts.
    
    * add ContentDisposition.getFilenameSanitized and use it in the entity part 
code
    
    * keep the entity part filename raw for pass-through re-serialisation
---
 .../org/apache/cxf/attachment/AttachmentUtil.java  |  5 +-
 .../apache/cxf/attachment/ContentDisposition.java  | 18 ++++++
 .../java/org/apache/cxf/helpers/FileUtils.java     | 20 +++++++
 .../apache/cxf/attachment/AttachmentUtilTest.java  | 45 +++++++++++++++
 .../cxf/attachment/ContentDispositionTest.java     | 15 ++++-
 .../java/org/apache/cxf/helpers/FileUtilsTest.java | 30 ++++++++++
 .../cxf/jaxrs/provider/EntityPartProvider.java     |  8 ++-
 .../apache/cxf/jaxrs/utils/EntityPartUtils.java    |  8 +--
 .../cxf/jaxrs/provider/EntityPartProviderTest.java | 66 +++++++++++++++++++++-
 9 files changed, 205 insertions(+), 10 deletions(-)

diff --git a/core/src/main/java/org/apache/cxf/attachment/AttachmentUtil.java 
b/core/src/main/java/org/apache/cxf/attachment/AttachmentUtil.java
index 3ea312238e1..ee5ab49b88c 100644
--- a/core/src/main/java/org/apache/cxf/attachment/AttachmentUtil.java
+++ b/core/src/main/java/org/apache/cxf/attachment/AttachmentUtil.java
@@ -442,8 +442,9 @@ public final class AttachmentUtil {
             headers.remove("Content-Transfer-Encoding");
         }
         DataSource source = new AttachmentDataSource(ct, ins);
-        if (!StringUtils.isEmpty(fileName)) {
-            
((AttachmentDataSource)source).setName(FileUtils.stripPath(fileName));
+        String safeFileName = FileUtils.safeFileName(fileName);
+        if (!StringUtils.isEmpty(safeFileName)) {
+            ((AttachmentDataSource)source).setName(safeFileName);
         }
         att.setDataHandler(new DataHandler(source));
         return att;
diff --git 
a/core/src/main/java/org/apache/cxf/attachment/ContentDisposition.java 
b/core/src/main/java/org/apache/cxf/attachment/ContentDisposition.java
index 44a2d710270..ce9f72814f6 100644
--- a/core/src/main/java/org/apache/cxf/attachment/ContentDisposition.java
+++ b/core/src/main/java/org/apache/cxf/attachment/ContentDisposition.java
@@ -26,6 +26,8 @@ import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.apache.cxf.helpers.FileUtils;
+
 public class ContentDisposition {
     private static final String CD_HEADER_PARAMS_EXPRESSION =
        "[\\w-]++( )?\\*?=( )?((\"[^\"]++\")|([^;]+))";
@@ -127,10 +129,26 @@ public class ContentDisposition {
         return type;
     }
 
+    /**
+     * Returns the raw filename as it appeared in the header. The value is 
parsed
+     * straight from attacker-controlled input and is not sanitised here, so 
it may
+     * still contain path separators or traversal sequences; callers building 
a file
+     * from it should use {@link #getFilenameSanitized()} instead.
+     */
     public String getFilename() {
         return params.get(FILE_NAME);
     }
 
+    /**
+     * Returns the filename reduced by {@link FileUtils#safeFileName(String)} 
to a
+     * value that is safe to build a file from, or null if the header carried 
no
+     * filename or nothing usable is left once it is sanitised (for example 
".."
+     * or a name ending in a path separator).
+     */
+    public String getFilenameSanitized() {
+        return FileUtils.safeFileName(getFilename());
+    }
+
     public String getParameter(String name) {
         return params.get(name);
     }
diff --git a/core/src/main/java/org/apache/cxf/helpers/FileUtils.java 
b/core/src/main/java/org/apache/cxf/helpers/FileUtils.java
index 18a330da139..6795baf1b71 100644
--- a/core/src/main/java/org/apache/cxf/helpers/FileUtils.java
+++ b/core/src/main/java/org/apache/cxf/helpers/FileUtils.java
@@ -349,4 +349,24 @@ public final class FileUtils {
         return name;
     }
 
+    /**
+     * Reduces an untrusted file name, for example one parsed from an inbound
+     * Content-Disposition header, to a value that is safe to build a file 
from.
+     * Any directory component is stripped (see {@link #stripPath(String)}) 
and a
+     * name that would still walk a caller doing {@code new File(dir, name)} 
out of
+     * its directory - "." or "..", or one that strips down to nothing - is 
rejected
+     * by returning null. A NUL or a ':' (which Windows can treat as a 
drive-relative
+     * path) is neutralised to '_' rather than rejected, so an otherwise valid 
name
+     * such as "Meeting 10:30.pdf" keeps a usable form. Callers should treat a 
null
+     * result as the part having no usable file name.
+     */
+    public static String safeFileName(String name) {
+        String stripped = stripPath(name);
+        if (stripped == null || stripped.isEmpty()
+            || ".".equals(stripped) || "..".equals(stripped)) {
+            return null;
+        }
+        return stripped.replace('\0', '_').replace(':', '_');
+    }
+
 }
diff --git 
a/core/src/test/java/org/apache/cxf/attachment/AttachmentUtilTest.java 
b/core/src/test/java/org/apache/cxf/attachment/AttachmentUtilTest.java
index 41c76ba274d..5637d077005 100644
--- a/core/src/test/java/org/apache/cxf/attachment/AttachmentUtilTest.java
+++ b/core/src/test/java/org/apache/cxf/attachment/AttachmentUtilTest.java
@@ -41,6 +41,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.endsWith;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 
@@ -408,4 +409,48 @@ public class AttachmentUtilTest {
             new ByteArrayInputStream("3".getBytes(StandardCharsets.UTF_8)), 
headers, message);
         return att.getDataHandler().getContentType();
     }
+
+    @Test
+    public void testCreateAttachmentStripsPathFromFilename() throws 
IOException {
+        assertEquals("passwd",
+            createAttachmentName("form-data; name=\"file\"; 
filename=\"../../../etc/passwd\""));
+        assertEquals("secret.txt",
+            createAttachmentName("form-data; name=\"file\"; 
filename=\"..\\..\\secret.txt\""));
+    }
+
+    @Test
+    public void testCreateAttachmentStripsExtendedFilenameTraversal() throws 
IOException {
+        // CXF percent-decodes the filename* value, turning %2e%2e%2f back 
into ../
+        assertEquals("passwd",
+            createAttachmentName("form-data; name=\"file\"; 
filename*=UTF-8''%2e%2e%2f%2e%2e%2fpasswd"));
+    }
+
+    @Test
+    public void testCreateAttachmentStripsCodepointEncodedTraversal() throws 
IOException {
+        // &#NNNN; references are decoded too: &#0046;&#0046;&#0047; becomes 
../
+        assertEquals("x",
+            createAttachmentName("form-data; name=\"file\"; 
filename=\"&#0046;&#0046;&#0047;x\""));
+    }
+
+    @Test
+    public void testCreateAttachmentDropsUnsafeFilename() throws IOException {
+        // ".." and a trailing separator leave nothing usable, so no name is 
set
+        assertNull(createAttachmentName("form-data; name=\"file\"; 
filename=\"..\""));
+        assertNull(createAttachmentName("form-data; name=\"file\"; 
filename=\"reports/\""));
+    }
+
+    @Test
+    public void testCreateAttachmentNeutralisesNulInFilename() throws 
IOException {
+        // a NUL is replaced rather than dropped, so the part keeps a usable 
name
+        assertEquals("shell.jsp_.png",
+            createAttachmentName("form-data; name=\"file\"; 
filename=\"shell.jsp\u0000.png\""));
+    }
+
+    private static String createAttachmentName(String cd) throws IOException {
+        Map<String, List<String>> headers = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        headers.put("Content-Disposition", Collections.singletonList(cd));
+        Attachment att = AttachmentUtil.createAttachment(
+            new ByteArrayInputStream("3".getBytes(StandardCharsets.UTF_8)), 
headers, new MessageImpl());
+        return att.getDataHandler().getName();
+    }
 }
diff --git 
a/core/src/test/java/org/apache/cxf/attachment/ContentDispositionTest.java 
b/core/src/test/java/org/apache/cxf/attachment/ContentDispositionTest.java
index 45f503dac19..9c104415723 100644
--- a/core/src/test/java/org/apache/cxf/attachment/ContentDispositionTest.java
+++ b/core/src/test/java/org/apache/cxf/attachment/ContentDispositionTest.java
@@ -45,4 +45,17 @@ public class ContentDispositionTest {
 
         assertNull(cd.getFilename());
     }
-}
\ No newline at end of file
+
+    @Test
+    public void testGetFilenameSanitized() {
+        ContentDisposition cd = new 
ContentDisposition("attachment;filename=\"../../../etc/passwd\"");
+        assertEquals("../../../etc/passwd", cd.getFilename());
+        assertEquals("passwd", cd.getFilenameSanitized());
+
+        cd = new 
ContentDisposition("attachment;filename*=UTF-8''%2e%2e%2f%2e%2e%2fpasswd");
+        assertEquals("passwd", cd.getFilenameSanitized());
+
+        assertNull(new 
ContentDisposition("attachment;filename=\"..\"").getFilenameSanitized());
+        assertNull(new 
ContentDisposition("attachment;name=\"field\"").getFilenameSanitized());
+    }
+}
diff --git a/core/src/test/java/org/apache/cxf/helpers/FileUtilsTest.java 
b/core/src/test/java/org/apache/cxf/helpers/FileUtilsTest.java
index f816dd9c2c0..32dd2df7dbe 100644
--- a/core/src/test/java/org/apache/cxf/helpers/FileUtilsTest.java
+++ b/core/src/test/java/org/apache/cxf/helpers/FileUtilsTest.java
@@ -29,7 +29,9 @@ import java.util.Optional;
 
 import org.junit.Test;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 public class FileUtilsTest {
@@ -78,4 +80,32 @@ public class FileUtilsTest {
 
         assertTrue(foundFiles.containsAll(foundFiles2) && 
foundFiles2.containsAll(foundFiles));
     }
+
+    @Test
+    public void testSafeFileNameKeepsFinalSegment() {
+        assertEquals("passwd", FileUtils.safeFileName("../../../etc/passwd"));
+        assertEquals("secret.txt", 
FileUtils.safeFileName("..\\..\\secret.txt"));
+        assertEquals("report.pdf", FileUtils.safeFileName("report.pdf"));
+    }
+
+    @Test
+    public void testSafeFileNameRejectsTraversalAndEmpty() {
+        assertNull(FileUtils.safeFileName(".."));
+        assertNull(FileUtils.safeFileName("."));
+        assertNull(FileUtils.safeFileName("a/.."));
+        // a trailing separator strips down to an empty name, which would 
resolve
+        // back to the upload directory itself
+        assertNull(FileUtils.safeFileName("x/"));
+        assertNull(FileUtils.safeFileName(""));
+        assertNull(FileUtils.safeFileName(null));
+    }
+
+    @Test
+    public void testSafeFileNameNeutralisesNulAndColon() {
+        // NUL and ':' are replaced rather than rejected, so an otherwise valid
+        // name keeps a usable form instead of being dropped altogether
+        assertEquals("shell.jsp_.png", 
FileUtils.safeFileName("shell.jsp\u0000.png"));
+        assertEquals("C_evil.exe", FileUtils.safeFileName("C:evil.exe"));
+        assertEquals("Meeting 10_30.pdf", FileUtils.safeFileName("Meeting 
10:30.pdf"));
+    }
 }
diff --git 
a/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/provider/EntityPartProvider.java
 
b/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/provider/EntityPartProvider.java
index e2be13b641b..80a623e52d2 100644
--- 
a/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/provider/EntityPartProvider.java
+++ 
b/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/provider/EntityPartProvider.java
@@ -169,9 +169,13 @@ public class EntityPartProvider extends 
AbstractConfigurableProvider
             final String cdName = cd == null ? null : cd.getParameter("name");
             final String contentId = attachment.getContentId();
 
+            // the filename is carried through verbatim: EntityPartImpl passes 
it straight
+            // back out when the part is re-serialised, so sanitising it here 
would corrupt
+            // a part that is simply read and forwarded. Callers that build a 
file from the
+            // name should reduce it through 
ContentDisposition.getFilenameSanitized().
             if (!StringUtils.isEmpty(fileName)) {
-                return new EntityPartImpl(mc.getProviders(), contentId, 
fileName, is, c, genericType,
-                    attachment.getHeaders(), attachment.getContentType());
+                return new EntityPartImpl(mc.getProviders(), contentId, 
fileName, is, c,
+                    genericType, attachment.getHeaders(), 
attachment.getContentType());
             } else {
                 final String name = StringUtils.isEmpty(cdName) ? contentId : 
cdName.replace("\"", "").replace("'", "");
                 return new EntityPartImpl(mc.getProviders(), name, null, is, 
c, genericType, attachment.getHeaders(),
diff --git 
a/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/utils/EntityPartUtils.java
 
b/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/utils/EntityPartUtils.java
index 6eeb8a13f1b..5d33d8d3dd7 100644
--- 
a/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/utils/EntityPartUtils.java
+++ 
b/rt/frontend/jaxrs/src/main/java/org/apache/cxf/jaxrs/utils/EntityPartUtils.java
@@ -59,8 +59,8 @@ final class EntityPartUtils {
                 final String fileName = (cd != null) ? cd.getFilename() : null;
 
                 if (!StringUtils.isEmpty(fileName)) {
-                    return new EntityPartImpl(providers, key, fileName, value, 
String.class, null,
-                        attachment.getHeaders(), attachment.getContentType());
+                    return new EntityPartImpl(providers, key, fileName, value, 
String.class,
+                        null, attachment.getHeaders(), 
attachment.getContentType());
                 } else {
                     return new EntityPartImpl(providers, key, null, value, 
String.class, null, attachment.getHeaders(),
                         attachment.getContentType());
@@ -110,8 +110,8 @@ final class EntityPartUtils {
         try {
             final InputStream is = a.getDataHandler().getInputStream();
             if (!StringUtils.isEmpty(fileName)) {
-                return new EntityPartImpl(providers, name, fileName, is, 
InputStream.class, null,
-                    a.getHeaders(), a.getContentType());
+                return new EntityPartImpl(providers, name, fileName, is, 
InputStream.class,
+                    null, a.getHeaders(), a.getContentType());
             } else {
                 return new EntityPartImpl(providers, name, null, is, 
InputStream.class, null,
                     a.getHeaders(), a.getContentType());
diff --git 
a/rt/frontend/jaxrs/src/test/java/org/apache/cxf/jaxrs/provider/EntityPartProviderTest.java
 
b/rt/frontend/jaxrs/src/test/java/org/apache/cxf/jaxrs/provider/EntityPartProviderTest.java
index 098009366ef..af765c3d6f7 100644
--- 
a/rt/frontend/jaxrs/src/test/java/org/apache/cxf/jaxrs/provider/EntityPartProviderTest.java
+++ 
b/rt/frontend/jaxrs/src/test/java/org/apache/cxf/jaxrs/provider/EntityPartProviderTest.java
@@ -25,6 +25,7 @@ import java.lang.annotation.Annotation;
 import java.lang.reflect.Type;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
+import java.util.Optional;
 import java.util.stream.IntStream;
 
 import jakarta.ws.rs.core.EntityPart;
@@ -161,10 +162,73 @@ public class EntityPartProviderTest {
         msg.put(Message.CONTENT_TYPE, "multipart/related");
 
         assertThrows("Failure expected on too many attachment headers", 
RuntimeException.class,
-                () -> p.readFrom((Class<List<EntityPart>>) (Class<?>) 
List.class, 
+                () -> p.readFrom((Class<List<EntityPart>>) (Class<?>) 
List.class,
                     EntityPart.class, new Annotation[]{},
                     MediaType.APPLICATION_OCTET_STREAM_TYPE,
                     new MetadataMap<String, String>(),
                     msg.getContent(InputStream.class)));
     }
+
+    @Test
+    public void testFilenameIsCarriedThroughRaw() throws Exception {
+        // the EntityPart keeps the filename verbatim so a read-and-forwarded 
part is not
+        // corrupted; sanitising is left to callers via 
ContentDisposition.getFilenameSanitized()
+        final List<EntityPart> parts = readParts("form-data; name=\"file\"; 
filename=\"../../../etc/passwd\"");
+        assertEquals(1, parts.size());
+        assertEquals(Optional.of("../../../etc/passwd"), 
parts.get(0).getFileName());
+    }
+
+    @Test
+    public void testPlainFilenamePassesThrough() throws Exception {
+        final List<EntityPart> parts = readParts("form-data; name=\"file\"; 
filename=\"report.pdf\"");
+        assertEquals(1, parts.size());
+        assertEquals(Optional.of("report.pdf"), parts.get(0).getFileName());
+    }
+
+    @Test
+    public void testTrailingSeparatorStaysAFilePart() throws Exception {
+        // a non-empty filename in the header makes this a file part (named by 
its
+        // content id) rather than a plain form field, and the value is kept 
raw
+        final List<EntityPart> parts = readParts("form-data; name=\"file\"; 
filename=\"reports/\"");
+        assertEquals(1, parts.size());
+        assertEquals(Optional.of("reports/"), parts.get(0).getFileName());
+        assertEquals("file-part", parts.get(0).getName());
+    }
+
+    @Test
+    public void testEmptyFilenameIsAFormField() throws Exception {
+        // browsers send filename="" for an empty HTML file input, so an empty 
value
+        // stays a plain form field named after its part name rather than a 
file part
+        final List<EntityPart> parts = readParts("form-data; name=\"file\"; 
filename=\"\"");
+        assertEquals(1, parts.size());
+        assertEquals(Optional.empty(), parts.get(0).getFileName());
+        assertEquals("file", parts.get(0).getName());
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<EntityPart> readParts(final String contentDisposition) throws 
Exception {
+        final Exchange exchange = new ExchangeImpl();
+        final EntityPartProvider p = new EntityPartProvider();
+
+        final String boundary = "----=_Part_34950_1098328613.1263781527359";
+        final String body = "--" + boundary + "\n"
+            + "Content-Type: application/octet-stream\n"
+            + "Content-Disposition: " + contentDisposition + "\n"
+            + "Content-Id: <file-part>\n"
+            + '\n'
+            + "some bytes\n"
+            + "--" + boundary + "--\n";
+
+        final Message msg = new MessageImpl();
+        msg.setExchange(exchange);
+        exchange.setInMessage(msg);
+        p.setMessageContext(new MessageContextImpl(msg));
+        p.setProviders(providers);
+
+        msg.setContent(InputStream.class, new 
ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
+        msg.put(Message.CONTENT_TYPE, "multipart/form-data; boundary=\"" + 
boundary + "\"");
+
+        return p.readFrom((Class<List<EntityPart>>) (Class<?>) List.class, 
EntityPart.class, new Annotation[]{},
+            MediaType.MULTIPART_FORM_DATA_TYPE, new MetadataMap<String, 
String>(), msg.getContent(InputStream.class));
+    }
 }

Reply via email to