This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new d2a6b0269bf3 [backport camel-4.18.x] CAMEL-24427: camel-servlet,
camel-jetty - enforce fileNameExtWhitelist on the submitted file name (#26321)
d2a6b0269bf3 is described below
commit d2a6b0269bf3a5c020478d33e78218461004f9ea
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Sep 11 15:33:08 2026 +0200
[backport camel-4.18.x] CAMEL-24427: camel-servlet, camel-jetty - enforce
fileNameExtWhitelist on the submitted file name (#26321)
---
.../component/jetty12/AttachmentHttpBinding.java | 59 ++++++++++--
.../MultiPartFormFileNameExtWhitelistTest.java | 101 +++++++++++++++++++++
.../component/servlet/AttachmentHttpBinding.java | 22 ++++-
.../MultipartUploadFileNameExtWhitelistTest.java | 91 +++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_18.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_18.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index b2d8f08edd7f..6f66a5feafce 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
@@ -1708,6 +1708,32 @@ default, stop seeing in-progress aggregations
re-delivered, and start seeing gen
now also holds one entry per completed and not yet confirmed exchange; those
entries are removed on
confirmation.
+=== 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.
+
=== camel-vertx-http - the REST producer applies the outbound header filter
`VertxHttpRestHeaderFilterStrategy.applyFilterToCamelHeaders` delegated to