From 001171f8eb80caaa8acd5160aef0b0f5ca24f81b Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Fri, 1 May 2026 11:17:34 +0100 Subject: [PATCH] Harden deserialization in HttpRestartServer While remote code execution is a feature of remote DevTools, hardening of the deserialization of ClassLoaderFiles is not without benefit. Not least, it should prevent false-positive reports from AI-based security scanners that look at the code in isolation without understanding the full context of the feature. It should be noted that this hardening in no way protects against remote code execution and the use of remote DevTools remains an opt-in feature that should only be enabled in a trusted setting and secured with a sufficiently complex secret. It remains the case that an attacker who compromises the secret and has network access to the remote application can achieve RCE by uploading a serialized ClassLoaderFiles payload that adds malicious code and/or resources to the application. Closes gh-50272 --- .../restart/server/HttpRestartServer.java | 32 ++++++++++++++++++- .../server/HttpRestartServerTests.java | 18 +++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/server/HttpRestartServer.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/server/HttpRestartServer.java index 6eae1ef9a8f..508ffb8cfbe 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/server/HttpRestartServer.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/server/HttpRestartServer.java @@ -17,12 +17,17 @@ package org.springframework.boot.devtools.restart.server; import java.io.IOException; +import java.io.ObjectInputFilter; import java.io.ObjectInputStream; +import java.util.Map; +import java.util.Set; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles; +import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles.SourceDirectory; import org.springframework.http.HttpStatus; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; @@ -40,6 +45,8 @@ public class HttpRestartServer { private static final Log logger = LogFactory.getLog(HttpRestartServer.class); + private final ObjectInputFilter inputFilter = new ClassLoaderFilesObjectInputFilter(); + private final RestartServer server; /** @@ -71,15 +78,38 @@ public class HttpRestartServer { try { Assert.state(request.getHeaders().getContentLength() > 0, "No content"); ObjectInputStream objectInputStream = new ObjectInputStream(request.getBody()); + objectInputStream.setObjectInputFilter(this.inputFilter); ClassLoaderFiles files = (ClassLoaderFiles) objectInputStream.readObject(); objectInputStream.close(); this.server.updateAndRestart(files); response.setStatusCode(HttpStatus.OK); } catch (Exception ex) { - logger.warn("Unable to handler restart server HTTP request", ex); + logger.warn("Unable to handle restart server HTTP request", ex); response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); } } + private static final class ClassLoaderFilesObjectInputFilter implements ObjectInputFilter { + + private static final Set> PERMITTED_CLASSES = Set.of(ClassLoaderFiles.class, ClassLoaderFile.class, + ClassLoaderFile.Kind.class, SourceDirectory.class, java.lang.Enum.class, Map.Entry.class, byte.class); + + @Override + public Status checkInput(FilterInfo filterInfo) { + Class serialClass = filterInfo.serialClass(); + if (serialClass == null) { + return Status.UNDECIDED; + } + while (serialClass.isArray()) { + serialClass = serialClass.componentType(); + } + if (PERMITTED_CLASSES.contains(serialClass) || Map.class.isAssignableFrom(serialClass)) { + return Status.ALLOWED; + } + return Status.REJECTED; + } + + } + } diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/server/HttpRestartServerTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/server/HttpRestartServerTests.java index 6da9ef7781e..26e5ff276ee 100644 --- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/server/HttpRestartServerTests.java +++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/server/HttpRestartServerTests.java @@ -18,7 +18,9 @@ package org.springframework.boot.devtools.restart.server; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InvalidClassException; import java.io.ObjectOutputStream; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +31,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile.Kind; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.mock.web.MockHttpServletRequest; @@ -104,6 +108,20 @@ class HttpRestartServerTests { assertThat(response.getStatus()).isEqualTo(500); } + @Test + @ExtendWith(OutputCaptureExtension.class) + void sendBadSerializedData(CapturedOutput output) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] bytes = serialize(List.of("not", "allowed")); + request.setContent(bytes); + this.server.handle(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response)); + then(this.delegate).shouldHaveNoInteractions(); + assertThat(response.getStatus()).isEqualTo(500); + assertThat(output).contains(InvalidClassException.class.getName()) + .doesNotContain(ClassCastException.class.getName()); + } + private byte[] serialize(Object object) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos);