Skip to content

Commit 001171f

Browse files
committed
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
1 parent 03eb75f commit 001171f

2 files changed

Lines changed: 49 additions & 1 deletion

File tree

spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/server/HttpRestartServer.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@
1717
package org.springframework.boot.devtools.restart.server;
1818

1919
import java.io.IOException;
20+
import java.io.ObjectInputFilter;
2021
import java.io.ObjectInputStream;
22+
import java.util.Map;
23+
import java.util.Set;
2124

2225
import org.apache.commons.logging.Log;
2326
import org.apache.commons.logging.LogFactory;
2427

28+
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile;
2529
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles;
30+
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles.SourceDirectory;
2631
import org.springframework.http.HttpStatus;
2732
import org.springframework.http.server.ServerHttpRequest;
2833
import org.springframework.http.server.ServerHttpResponse;
@@ -40,6 +45,8 @@ public class HttpRestartServer {
4045

4146
private static final Log logger = LogFactory.getLog(HttpRestartServer.class);
4247

48+
private final ObjectInputFilter inputFilter = new ClassLoaderFilesObjectInputFilter();
49+
4350
private final RestartServer server;
4451

4552
/**
@@ -71,15 +78,38 @@ public void handle(ServerHttpRequest request, ServerHttpResponse response) throw
7178
try {
7279
Assert.state(request.getHeaders().getContentLength() > 0, "No content");
7380
ObjectInputStream objectInputStream = new ObjectInputStream(request.getBody());
81+
objectInputStream.setObjectInputFilter(this.inputFilter);
7482
ClassLoaderFiles files = (ClassLoaderFiles) objectInputStream.readObject();
7583
objectInputStream.close();
7684
this.server.updateAndRestart(files);
7785
response.setStatusCode(HttpStatus.OK);
7886
}
7987
catch (Exception ex) {
80-
logger.warn("Unable to handler restart server HTTP request", ex);
88+
logger.warn("Unable to handle restart server HTTP request", ex);
8189
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
8290
}
8391
}
8492

93+
private static final class ClassLoaderFilesObjectInputFilter implements ObjectInputFilter {
94+
95+
private static final Set<Class<?>> PERMITTED_CLASSES = Set.of(ClassLoaderFiles.class, ClassLoaderFile.class,
96+
ClassLoaderFile.Kind.class, SourceDirectory.class, java.lang.Enum.class, Map.Entry.class, byte.class);
97+
98+
@Override
99+
public Status checkInput(FilterInfo filterInfo) {
100+
Class<?> serialClass = filterInfo.serialClass();
101+
if (serialClass == null) {
102+
return Status.UNDECIDED;
103+
}
104+
while (serialClass.isArray()) {
105+
serialClass = serialClass.componentType();
106+
}
107+
if (PERMITTED_CLASSES.contains(serialClass) || Map.class.isAssignableFrom(serialClass)) {
108+
return Status.ALLOWED;
109+
}
110+
return Status.REJECTED;
111+
}
112+
113+
}
114+
85115
}

spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/server/HttpRestartServerTests.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
import java.io.ByteArrayOutputStream;
2020
import java.io.IOException;
21+
import java.io.InvalidClassException;
2122
import java.io.ObjectOutputStream;
23+
import java.util.List;
2224

2325
import org.junit.jupiter.api.BeforeEach;
2426
import org.junit.jupiter.api.Test;
@@ -29,6 +31,8 @@
2931
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile;
3032
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile.Kind;
3133
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles;
34+
import org.springframework.boot.test.system.CapturedOutput;
35+
import org.springframework.boot.test.system.OutputCaptureExtension;
3236
import org.springframework.http.server.ServletServerHttpRequest;
3337
import org.springframework.http.server.ServletServerHttpResponse;
3438
import org.springframework.mock.web.MockHttpServletRequest;
@@ -104,6 +108,20 @@ void sendBadData() throws Exception {
104108
assertThat(response.getStatus()).isEqualTo(500);
105109
}
106110

111+
@Test
112+
@ExtendWith(OutputCaptureExtension.class)
113+
void sendBadSerializedData(CapturedOutput output) throws Exception {
114+
MockHttpServletRequest request = new MockHttpServletRequest();
115+
MockHttpServletResponse response = new MockHttpServletResponse();
116+
byte[] bytes = serialize(List.of("not", "allowed"));
117+
request.setContent(bytes);
118+
this.server.handle(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response));
119+
then(this.delegate).shouldHaveNoInteractions();
120+
assertThat(response.getStatus()).isEqualTo(500);
121+
assertThat(output).contains(InvalidClassException.class.getName())
122+
.doesNotContain(ClassCastException.class.getName());
123+
}
124+
107125
private byte[] serialize(Object object) throws IOException {
108126
ByteArrayOutputStream bos = new ByteArrayOutputStream();
109127
ObjectOutputStream oos = new ObjectOutputStream(bos);

0 commit comments

Comments
 (0)