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 fd120518ecbd CAMEL-24427: camel-servlet, camel-jetty - enforce
fileNameExtWhitelist on the submitted file name (#26189)
fd120518ecbd is described below
commit fd120518ecbd591b05853434225587d2bd76b2fd
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Sep 8 15:24:55 2026 +0200
CAMEL-24427: camel-servlet, camel-jetty - enforce fileNameExtWhitelist on
the submitted file name (#26189)
camel-servlet's AttachmentHttpBinding checked the whitelist against
Part.getName(), which is the multipart field name and not the submitted file
name. A field named "file" carries no extension, so FileUtil.onlyExt
returned
null and no upload was ever rejected. It now checks
Part.getSubmittedFileName(), which is what camel-platform-http-vertx already
does.
The camel-jetty binding performed no whitelist check at all, although
fileNameExtWhitelist can be set on its HttpBinding. It now applies the same
check.
The camel-jetty binding also stored the attachment under the multipart field
name but looked it up again by the submitted file name, so the lookup only
succeeded when the two happened to be equal, and the resulting header was
named
by the client-supplied file name. The attachment is now looked up and
exposed
under the field name it is stored with, and only for parts that carry a file
name.
The whitelist is matched per comma-separated extension exactly rather than
with
a substring test, and the client-controlled file name is passed through
HttpHelper.sanitizeLog before being logged.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../component/jetty12/AttachmentHttpBinding.java | 59 ++++++++++--
.../MultiPartFormFileNameExtWhitelistTest.java | 101 +++++++++++++++++++++
.../component/servlet/AttachmentHttpBinding.java | 22 ++++-
.../MultipartUploadFileNameExtWhitelistTest.java | 91 +++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 26 ++++++
5 files changed, 285 insertions(+), 14 deletions(-)
diff --git
a/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/AttachmentHttpBinding.java
b/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/AttachmentHttpBinding.java
index fd15b719c2cc..872a6615ff1d 100644
---
a/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/AttachmentHttpBinding.java
+++
b/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/AttachmentHttpBinding.java
@@ -21,6 +21,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.util.Collection;
import java.util.Enumeration;
+import java.util.Locale;
import java.util.Map;
import jakarta.activation.DataHandler;
@@ -37,6 +38,7 @@ import org.apache.camel.attachment.DefaultAttachmentMessage;
import org.apache.camel.component.jetty.MultiPartFilter;
import org.apache.camel.http.common.DefaultHttpBinding;
import org.apache.camel.http.common.HttpHelper;
+import org.apache.camel.util.FileUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -59,6 +61,16 @@ final class AttachmentHttpBinding extends DefaultHttpBinding
{
try {
parts = request.getParts();
for (Part part : parts) {
+ // the whitelist accepts file name extensions, so it must
be checked against the submitted
+ // file name and not against Part.getName(), which is the
multipart field name
+ String fileName = part.getSubmittedFileName();
+ if (!isFileNameAccepted(fileName)) {
+ LOG.debug(
+ "Cannot add file as attachment: {} because the
file is not accepted according to fileNameExtWhitelist: {}",
+ HttpHelper.sanitizeLog(fileName),
getFileNameExtWhitelist());
+ continue;
+ }
+
DataSource ds = new PartDataSource(part);
Attachment attachment = new DefaultAttachment(ds);
for (String headerName : part.getHeaderNames()) {
@@ -67,16 +79,21 @@ final class AttachmentHttpBinding extends
DefaultHttpBinding {
}
}
AttachmentMessage am = new
DefaultAttachmentMessage(message);
- am.addAttachmentObject(part.getName(), attachment);
- String name = part.getSubmittedFileName();
- Object value = am.getAttachment(name);
- Map<String, Object> headers = message.getHeaders();
- if (getHeaderFilterStrategy() != null
- &&
!getHeaderFilterStrategy().applyFilterToExternalHeaders(name, value,
message.getExchange())
- && name != null) {
- HttpHelper.appendHeader(headers, name, value);
+ String name = part.getName();
+ am.addAttachmentObject(name, attachment);
+ // a file part is also exposed as a header carrying the
DataHandler. The attachment is keyed on
+ // the multipart field name, so the header must be looked
up and named by that same key and not
+ // by the client supplied file name. A plain form field
carries no file name and is mapped by
+ // populateRequestParameters instead, so it is left alone
here.
+ if (fileName != null && name != null) {
+ Object value = am.getAttachment(name);
+ Map<String, Object> headers = message.getHeaders();
+ if (getHeaderFilterStrategy() != null
+ &&
!getHeaderFilterStrategy().applyFilterToExternalHeaders(name, value,
+ message.getExchange())) {
+ HttpHelper.appendHeader(headers, name, value);
+ }
}
-
}
} catch (Exception e) {
throw new RuntimeCamelException("Cannot populate attachments",
e);
@@ -84,6 +101,30 @@ final class AttachmentHttpBinding extends
DefaultHttpBinding {
}
}
+ private boolean isFileNameAccepted(String fileName) {
+ String whitelist = getFileNameExtWhitelist();
+ if (whitelist == null) {
+ return true;
+ }
+ String ext = FileUtil.onlyExt(fileName);
+ if (ext == null) {
+ return true;
+ }
+ ext = ext.toLowerCase(Locale.US);
+ whitelist = whitelist.toLowerCase(Locale.US);
+ if (whitelist.equals("*")) {
+ return true;
+ }
+ // compare against each comma-separated extension exactly, not as a
substring: a whitelist of "txt"
+ // must not accept an upload named "evil.x" just because
"txt".contains("x")
+ for (String allowed : whitelist.split(",")) {
+ if (allowed.trim().equals(ext)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
protected void populateRequestParameters(HttpServletRequest request,
Message message) {
// we populate the http request parameters without checking the request
diff --git
a/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/MultiPartFormFileNameExtWhitelistTest.java
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/MultiPartFormFileNameExtWhitelistTest.java
new file mode 100644
index 000000000000..a8ae1d0a55c5
--- /dev/null
+++
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/MultiPartFormFileNameExtWhitelistTest.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.jetty;
+
+import java.io.File;
+
+import jakarta.activation.DataHandler;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.attachment.AttachmentMessage;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpEntity;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The uploaded part carries the multipart field name {@code upload} and the
submitted file name
+ * {@code log4j2.properties}. The two differ, which is what tells the field
name apart from the file name in both the
+ * whitelist check and the header that exposes the attachment.
+ */
+public class MultiPartFormFileNameExtWhitelistTest extends BaseJettyTest {
+
+ private static final String FIELD_NAME = "upload";
+ private static final String FILE_NAME = "log4j2.properties";
+
+ @Test
+ public void testUploadAcceptedWhenExtensionIsWhitelisted() {
+ // the attachment is keyed on the field name, and so is the header
that exposes it. The client supplied
+ // file name must not become a header name.
+
assertThat(upload("/allowed")).isEqualTo("attachment=true,headerIsAttachment=true,fileNameHeader=false");
+ }
+
+ @Test
+ public void testUploadRejectedWhenExtensionIsNotWhitelisted() {
+
assertThat(upload("/rejected")).isEqualTo("attachment=false,headerIsAttachment=false,fileNameHeader=false");
+ }
+
+ @Test
+ public void
testUploadRejectedWhenExtensionIsOnlyASubstringOfTheWhitelist() {
+ // "propertiesx".contains("properties") is true, but exact matching
must reject the upload
+
assertThat(upload("/substring")).isEqualTo("attachment=false,headerIsAttachment=false,fileNameHeader=false");
+ }
+
+ private String upload(String path) {
+ File file = new File("src/test/resources/log4j2.properties");
+ HttpEntity entity = MultipartEntityBuilder.create()
+ .addBinaryBody(FIELD_NAME, file,
ContentType.APPLICATION_OCTET_STREAM, FILE_NAME)
+ .build();
+ return template.requestBody("http://localhost:" + getPort() + path,
entity, String.class);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ getContext().getGlobalOptions().put("CamelJettyTempDir",
"target");
+
+ from(whitelisted("/allowed", "properties"))
+
.process(MultiPartFormFileNameExtWhitelistTest::reportAttachment);
+ from(whitelisted("/rejected", "pdf"))
+
.process(MultiPartFormFileNameExtWhitelistTest::reportAttachment);
+ from(whitelisted("/substring", "propertiesx"))
+
.process(MultiPartFormFileNameExtWhitelistTest::reportAttachment);
+ }
+
+ private JettyHttpEndpoint whitelisted(String path, String
whitelist) {
+ // the whitelist is not exposed as a jetty endpoint option, it
is configured on the binding
+ JettyHttpEndpoint endpoint = getContext().getEndpoint(
+ "jetty://http://localhost:" + getPort() + path,
JettyHttpEndpoint.class);
+ endpoint.getHttpBinding().setFileNameExtWhitelist(whitelist);
+ return endpoint;
+ }
+ };
+ }
+
+ private static void reportAttachment(Exchange exchange) {
+ AttachmentMessage in = exchange.getIn(AttachmentMessage.class);
+ DataHandler attachment = in.getAttachment(FIELD_NAME);
+ Object headerByField = in.getHeader(FIELD_NAME);
+ exchange.getMessage().setBody("attachment=" + (attachment != null)
+ + ",headerIsAttachment=" + (attachment
!= null && headerByField == attachment)
+ + ",fileNameHeader=" +
(in.getHeader(FILE_NAME) != null));
+ }
+}
diff --git
a/components/camel-servlet/src/main/java/org/apache/camel/component/servlet/AttachmentHttpBinding.java
b/components/camel-servlet/src/main/java/org/apache/camel/component/servlet/AttachmentHttpBinding.java
index 0495a2cb9ab7..aeb78820d1b2 100644
---
a/components/camel-servlet/src/main/java/org/apache/camel/component/servlet/AttachmentHttpBinding.java
+++
b/components/camel-servlet/src/main/java/org/apache/camel/component/servlet/AttachmentHttpBinding.java
@@ -33,6 +33,7 @@ import org.apache.camel.attachment.AttachmentMessage;
import org.apache.camel.attachment.DefaultAttachment;
import org.apache.camel.attachment.DefaultAttachmentMessage;
import org.apache.camel.http.common.DefaultHttpBinding;
+import org.apache.camel.http.common.HttpHelper;
import org.apache.camel.util.FileUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,7 +55,9 @@ public final class AttachmentHttpBinding extends
DefaultHttpBinding {
try {
Collection<Part> parts = request.getParts();
for (Part part : parts) {
- String fileName = part.getName();
+ // the whitelist accepts file name extensions, so it must be
checked against the submitted file
+ // name and not against Part.getName(), which is the multipart
field name
+ String fileName = part.getSubmittedFileName();
// is the file name accepted
boolean accepted = true;
if (getFileNameExtWhitelist() != null) {
@@ -62,9 +65,7 @@ public final class AttachmentHttpBinding extends
DefaultHttpBinding {
if (ext != null) {
ext = ext.toLowerCase(Locale.US);
String whiteList =
getFileNameExtWhitelist().toLowerCase(Locale.US);
- if (!whiteList.equals("*") &&
!whiteList.contains(ext)) {
- accepted = false;
- }
+ accepted = whiteList.equals("*") ||
isExtWhitelisted(whiteList, ext);
}
}
@@ -81,7 +82,7 @@ public final class AttachmentHttpBinding extends
DefaultHttpBinding {
} else {
LOG.debug(
"Cannot add file as attachment: {} because the
file is not accepted according to fileNameExtWhitelist: {}",
- fileName, getFileNameExtWhitelist());
+ HttpHelper.sanitizeLog(fileName),
getFileNameExtWhitelist());
}
}
} catch (Exception e) {
@@ -89,6 +90,17 @@ public final class AttachmentHttpBinding extends
DefaultHttpBinding {
}
}
+ // compare against each comma-separated extension exactly, not as a
substring: a whitelist of "txt"
+ // must not accept an upload named "evil.x" just because
"txt".contains("x")
+ private static boolean isExtWhitelisted(String whitelist, String ext) {
+ for (String allowed : whitelist.split(",")) {
+ if (allowed.trim().equals(ext)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
public final class PartDataSource implements DataSource {
private final Part part;
diff --git
a/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/MultipartUploadFileNameExtWhitelistTest.java
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/MultipartUploadFileNameExtWhitelistTest.java
new file mode 100644
index 000000000000..17131da4a349
--- /dev/null
+++
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/MultipartUploadFileNameExtWhitelistTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.servlet;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import jakarta.servlet.MultipartConfigElement;
+
+import io.undertow.servlet.api.DeploymentInfo;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.attachment.AttachmentMessage;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The test harness posts a part whose multipart field name is {@code file}
and whose submitted file name is
+ * {@code test.txt}. The field name carries no extension, so checking the
whitelist against it never rejects anything.
+ */
+public class MultipartUploadFileNameExtWhitelistTest extends
ServletCamelRouterTestSupport {
+
+ @Override
+ protected DeploymentInfo getDeploymentInfo() {
+ DeploymentInfo deploymentInfo = super.getDeploymentInfo();
+ deploymentInfo.setDefaultMultipartConfig(new
MultipartConfigElement(System.getProperty("java.io.tmpdir")));
+ return deploymentInfo;
+ }
+
+ @Test
+ void testUploadAcceptedWhenExtensionIsWhitelisted() throws IOException {
+ assertEquals("accepted", upload("allowed"));
+ }
+
+ @Test
+ void testUploadRejectedWhenExtensionIsNotWhitelisted() throws IOException {
+ assertEquals("no attachment", upload("rejected"));
+ }
+
+ @Test
+ void testUploadRejectedWhenExtensionIsOnlyASubstringOfTheWhitelist()
throws IOException {
+ // the whitelist "txtdoc" must not accept a "test.txt" upload just
because "txtdoc".contains("txt")
+ assertEquals("no attachment", upload("substring"));
+ }
+
+ private String upload(String path) throws IOException {
+ InputStream body =
context.getTypeConverter().convertTo(InputStream.class, "Hello World");
+ PostMethodWebRequest request = new PostMethodWebRequest(
+ contextUrl + "/services/" + path, body, "multipart/form-data;
boundary=----Boundary");
+ return query(request).getText();
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("servlet:allowed?attachmentMultipartBinding=true&fileNameExtWhitelist=txt")
+
.process(MultipartUploadFileNameExtWhitelistTest::reportAttachment);
+
+
from("servlet:rejected?attachmentMultipartBinding=true&fileNameExtWhitelist=pdf")
+
.process(MultipartUploadFileNameExtWhitelistTest::reportAttachment);
+
+
from("servlet:substring?attachmentMultipartBinding=true&fileNameExtWhitelist=txtdoc")
+
.process(MultipartUploadFileNameExtWhitelistTest::reportAttachment);
+ }
+ };
+ }
+
+ private static void reportAttachment(Exchange exchange) {
+ AttachmentMessage message =
exchange.getMessage(AttachmentMessage.class);
+ boolean present = message.getAttachment("file") != null;
+ exchange.getMessage().setBody(present ? "accepted" : "no attachment");
+ }
+}
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 5a9f1abf0e83..fb25b895e6e9 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
@@ -1753,3 +1753,29 @@ custom `headerFilterStrategy` is used as-is and is
unaffected.
Routes that relied on one of those headers reaching the wire must set it
through the endpoint
configuration or supply a `headerFilterStrategy` that permits it.
+
+=== camel-servlet, camel-jetty - the multipart upload whitelist is enforced
against the submitted file name
+
+`fileNameExtWhitelist` accepts file name extensions, but `camel-servlet`'s
`AttachmentHttpBinding`
+checked it against `Part.getName()`, which is the multipart *field* name
rather than the submitted
+file name. A field named `file` carries no extension, so the check found
nothing to compare and every
+upload was accepted. The option is now checked against
`Part.getSubmittedFileName()`, which is what
+`camel-platform-http-vertx` already does.
+
+A `camel-servlet` consumer that sets `fileNameExtWhitelist` together with
`attachmentMultipartBinding=true`
+therefore starts rejecting uploads whose file extension is not listed, which
is what the option always
+advertised. Uploads with no file name, such as plain form fields, are
unaffected, and a route that does
+not set the option is unaffected. Review the configured extension list before
upgrading.
+
+The `camel-jetty` binding performed no whitelist check at all, although
`fileNameExtWhitelist` can be
+set on its `HttpBinding`. It now applies the same check.
+
+The `camel-jetty` binding also stored the attachment under the multipart field
name but looked it up
+again by the submitted file name, and passed that file name to
`HttpHelper.appendHeader`. The lookup
+therefore only succeeded when the two happened to be equal, and when it did
the header was named by
+the client-supplied file name. The attachment is now looked up and exposed
under the field name it is
+stored with, and only for parts that carry a file name — a plain form field is
mapped by
+`populateRequestParameters` as before. Because the old lookup by file name
returned `null` whenever the
+two names differed, that header never carried a usable `DataHandler` in the
first place; only when the
+names happened to be equal did it resolve, and then the name is unchanged. A
route that expected the
+attachment header under the uploaded file name should read it under the
multipart field name.