From d9e4b66eeeba7ab4eb892028f83207fa490633a2 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Wed, 18 Jun 2025 08:54:17 +0100 Subject: [PATCH] Add properties for new max part count and max part header size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To address CVE-2025-48976 and CVE-2025-48988, Tomcat 10.1.42 has introduced two new configuration settings – maxPartCount and maxPartHeaderSize. The default values for these configuration settings have proven hard to get right and some applications have had to increase the default limits. To ease their configuration in Spring Boot, this commit introduces configuration properties for the new settings: - server.tomcat.max-part-count (maxPartCount) - server.tomcat.max-part-header-size (maxPartHeaderSize) The defaults are aligned with those of Tomcat 10.1.42 (10 and 512 bytes respectively). Closes gh-45869 --- .../autoconfigure/web/ServerProperties.java | 30 +++++++++++++ .../TomcatWebServerFactoryCustomizer.java | 28 +++++++++++- .../web/ServerPropertiesTests.java | 25 ++++++++++- ...TomcatWebServerFactoryCustomizerTests.java | 43 ++++++++++++++++++- 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java index 84df6ddb3f0..cf1f62c4df9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java @@ -412,6 +412,20 @@ public class ServerProperties { */ private DataSize maxHttpFormPostSize = DataSize.ofMegabytes(2); + /** + * Maximum per-part header size permitted in a multipart/form-data request. + * Requests that exceed this limit will be rejected. A value of less than 0 means + * no limit. + */ + private DataSize maxPartHeaderSize = DataSize.ofBytes(512); + + /** + * Maximum total number of parts permitted in a multipart/form-data request. + * Requests that exceed this limit will be rejected. A value of less than 0 means + * no limit. + */ + private int maxPartCount = 10; + /** * Maximum amount of request body to swallow. */ @@ -528,6 +542,22 @@ public class ServerProperties { this.maxHttpFormPostSize = maxHttpFormPostSize; } + public DataSize getMaxPartHeaderSize() { + return this.maxPartHeaderSize; + } + + public void setMaxPartHeaderSize(DataSize maxPartHeaderSize) { + this.maxPartHeaderSize = maxPartHeaderSize; + } + + public int getMaxPartCount() { + return this.maxPartCount; + } + + public void setMaxPartCount(int maxPartCount) { + this.maxPartCount = maxPartCount; + } + public Accesslog getAccesslog() { return this.accesslog; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java index 8699299ee8b..d31d9ae9b60 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -119,6 +119,10 @@ public class TomcatWebServerFactoryCustomizer .asInt(DataSize::toBytes) .when((maxHttpFormPostSize) -> maxHttpFormPostSize != 0) .to((maxHttpFormPostSize) -> customizeMaxHttpFormPostSize(factory, maxHttpFormPostSize)); + map.from(properties::getMaxPartHeaderSize) + .asInt(DataSize::toBytes) + .to((maxPartHeaderSize) -> customizeMaxPartHeaderSize(factory, maxPartHeaderSize)); + map.from(properties::getMaxPartCount).to((maxPartCount) -> customizeMaxPartCount(factory, maxPartCount)); map.from(properties::getAccesslog) .when(ServerProperties.Tomcat.Accesslog::isEnabled) .to((enabled) -> customizeAccessLog(factory)); @@ -304,6 +308,28 @@ public class TomcatWebServerFactoryCustomizer factory.addConnectorCustomizers((connector) -> connector.setMaxPostSize(maxHttpFormPostSize)); } + private void customizeMaxPartCount(ConfigurableTomcatWebServerFactory factory, int maxPartCount) { + factory.addConnectorCustomizers((connector) -> { + try { + connector.setMaxPartCount(maxPartCount); + } + catch (NoSuchMethodError ex) { + // Tomcat < 10.1.42 + } + }); + } + + private void customizeMaxPartHeaderSize(ConfigurableTomcatWebServerFactory factory, int maxPartHeaderSize) { + factory.addConnectorCustomizers((connector) -> { + try { + connector.setMaxPartHeaderSize(maxPartHeaderSize); + } + catch (NoSuchMethodError ex) { + // Tomcat < 10.1.42 + } + }); + } + private void customizeAccessLog(ConfigurableTomcatWebServerFactory factory) { ServerProperties.Tomcat tomcatProperties = this.serverProperties.getTomcat(); AccessLogValve valve = new AccessLogValve(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java index 0bfeacf8ec6..df3d22d3975 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -253,6 +253,18 @@ class ServerPropertiesTests { assertThat(this.properties.getTomcat().getThreads().getMinSpare()).isEqualTo(10); } + @Test + void customizeTomcatMaxPartCount() { + bind("server.tomcat.max-part-count", "5"); + assertThat(this.properties.getTomcat().getMaxPartCount()).isEqualTo(5); + } + + @Test + void customizeTomcatMaxPartHeaderSize() { + bind("server.tomcat.max-part-header-size", "128"); + assertThat(this.properties.getTomcat().getMaxPartHeaderSize()).isEqualTo(DataSize.ofBytes(128)); + } + @Test void testCustomizeJettyAcceptors() { bind("server.jetty.threads.acceptors", "10"); @@ -392,6 +404,17 @@ class ServerPropertiesTests { .isEqualTo(getDefaultConnector().getMaxPostSize()); } + @Test + void tomcatMaxPartCountMatchesConnectorDefault() { + assertThat(this.properties.getTomcat().getMaxPartCount()).isEqualTo(getDefaultConnector().getMaxPartCount()); + } + + @Test + void tomcatMaxPartHeaderSizeMatchesConnectorDefault() { + assertThat(this.properties.getTomcat().getMaxPartHeaderSize().toBytes()) + .isEqualTo(getDefaultConnector().getMaxPartHeaderSize()); + } + @Test void tomcatUriEncodingMatchesConnectorDefault() { assertThat(this.properties.getTomcat().getUriEncoding().name()) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java index d5b4b7c6750..fbd6d37fd03 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java @@ -37,6 +37,8 @@ import org.springframework.boot.autoconfigure.web.ServerProperties.ForwardHeader import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.testsupport.classpath.ClassPathOverrides; +import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatWebServer; import org.springframework.boot.web.server.WebServer; @@ -45,6 +47,7 @@ import org.springframework.test.context.support.TestPropertySourceUtils; import org.springframework.util.unit.DataSize; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; /** * Tests for {@link TomcatWebServerFactoryCustomizer} @@ -60,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Parviz Rozikov * @author Moritz Halbritter */ +@DirtiesUrlFactories class TomcatWebServerFactoryCustomizerTests { private MockEnvironment environment; @@ -177,6 +181,37 @@ class TomcatWebServerFactoryCustomizerTests { (server) -> assertThat(server.getTomcat().getConnector().getMaxPostSize()).isEqualTo(10000)); } + @Test + void defaultMaxPartCount() { + customizeAndRunServer( + (server) -> assertThat(server.getTomcat().getConnector().getMaxPartCount()).isEqualTo(10)); + } + + @Test + void customMaxPartCount() { + bind("server.tomcat.max-part-count=5"); + customizeAndRunServer((server) -> assertThat(server.getTomcat().getConnector().getMaxPartCount()).isEqualTo(5)); + } + + @Test + void defaultMaxPartHeaderSize() { + customizeAndRunServer( + (server) -> assertThat(server.getTomcat().getConnector().getMaxPartHeaderSize()).isEqualTo(512)); + } + + @Test + void customMaxPartHeaderSize() { + bind("server.tomcat.max-part-header-size=4KB"); + customizeAndRunServer( + (server) -> assertThat(server.getTomcat().getConnector().getMaxPartHeaderSize()).isEqualTo(4096)); + } + + @Test + @ClassPathOverrides("org.apache.tomcat.embed:tomcat-embed-core:10.1.41") + void customizerIsCompatibleWithTomcatVersionsWithoutMaxPartCountAndMaxPartHeaderSize() { + assertThatNoException().isThrownBy(this::customizeAndRunServer); + } + @Test void defaultMaxHttpRequestHeaderSize() { customizeAndRunServer((server) -> assertThat( @@ -586,11 +621,17 @@ class TomcatWebServerFactoryCustomizerTests { Bindable.ofInstance(this.serverProperties)); } + private void customizeAndRunServer() { + customizeAndRunServer(null); + } + private void customizeAndRunServer(Consumer consumer) { TomcatWebServer server = customizeAndGetServer(); server.start(); try { - consumer.accept(server); + if (consumer != null) { + consumer.accept(server); + } } finally { server.stop();