Skip to content

Commit 6b93c56

Browse files
committed
resteasy: bound multipart body-map inspection and add UTF-8/text-plain handling
Cap collectBodyMap() by total accumulated values (not distinct field names) to close a bypass where a repeated field name could exhaust the limit. Filter to text/plain parts only and default undeclared charsets to UTF-8 for body-map values, matching the Jersey reference. Widen per-part read/header exceptions to Exception with debug logging so one malformed part doesn't abort body/filename/content collection for the whole request.
1 parent 5a8dcc6 commit 6b93c56

3 files changed

Lines changed: 317 additions & 17 deletions

File tree

dd-java-agent/instrumentation/resteasy/resteasy-appsec-3.0/src/main/java/datadog/trace/instrumentation/resteasy/MultipartFormDataReaderInstrumentation.java

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,10 @@
1616
import datadog.trace.api.gateway.RequestContext;
1717
import datadog.trace.api.gateway.RequestContextSlot;
1818
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
19-
import java.io.IOException;
20-
import java.util.ArrayList;
21-
import java.util.HashMap;
2219
import java.util.List;
2320
import java.util.Map;
2421
import java.util.function.BiFunction;
2522
import net.bytebuddy.asm.Advice;
26-
import org.jboss.resteasy.plugins.providers.multipart.InputPart;
2723
import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput;
2824

2925
@AutoService(InstrumenterModule.class)
@@ -67,8 +63,7 @@ public static class ReadFromAdvice {
6763
static void after(
6864
@Advice.Return final MultipartFormDataInput ret,
6965
@ActiveRequestContext RequestContext reqCtx,
70-
@Advice.Thrown(readOnly = false) Throwable t)
71-
throws IOException {
66+
@Advice.Thrown(readOnly = false) Throwable t) {
7267
if (ret == null || t != null) {
7368
return;
7469
}
@@ -85,14 +80,7 @@ static void after(
8580
}
8681

8782
if (callback != null) {
88-
Map<String, List<String>> m = new HashMap<>();
89-
for (Map.Entry<String, List<InputPart>> e : ret.getFormDataMap().entrySet()) {
90-
List<String> strings = new ArrayList<>();
91-
m.put(e.getKey(), strings);
92-
for (InputPart inputPart : e.getValue()) {
93-
strings.add(inputPart.getBodyAsString());
94-
}
95-
}
83+
Map<String, List<String>> m = MultipartHelper.collectBodyMap(ret);
9684

9785
Flow<Void> flow = callback.apply(reqCtx, m);
9886
BlockingException be =

dd-java-agent/instrumentation/resteasy/resteasy-appsec-3.0/src/main/java/datadog/trace/instrumentation/resteasy/MultipartHelper.java

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@
66
import datadog.trace.api.gateway.Flow;
77
import datadog.trace.api.gateway.RequestContext;
88
import datadog.trace.api.http.MultipartContentDecoder;
9-
import java.io.IOException;
109
import java.io.InputStream;
1110
import java.lang.reflect.Method;
1211
import java.util.ArrayList;
12+
import java.util.HashMap;
1313
import java.util.List;
1414
import java.util.Map;
1515
import java.util.Map.Entry;
1616
import org.jboss.resteasy.plugins.providers.multipart.InputPart;
1717
import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput;
18+
import org.slf4j.Logger;
19+
import org.slf4j.LoggerFactory;
1820

1921
public final class MultipartHelper {
2022

23+
private static final Logger log = LoggerFactory.getLogger(MultipartHelper.class);
24+
2125
public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes();
2226
public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount();
2327

@@ -35,6 +39,78 @@ private MultipartHelper() {}
3539
GET_HEADERS = m;
3640
}
3741

42+
/**
43+
* Builds the {@code server.request.body} map out of the multipart parts.
44+
*
45+
* <p>Only text/plain parts are collected, matching the Jersey reference: file parts are reported
46+
* separately via {@link #collectFilenames} / {@link #collectFilesContent} and must not also
47+
* consume the body-map budget. The number of collected values is capped by {@link
48+
* #MAX_FILES_TO_INSPECT}. The cap counts the total accumulated values across all field names, not
49+
* the distinct keys: {@code getFormDataMap()} already groups parts by field name, so a per-key
50+
* cap would be trivially bypassed by repeating the same field name on every part.
51+
*/
52+
public static Map<String, List<String>> collectBodyMap(MultipartFormDataInput ret) {
53+
Map<String, List<String>> bodyMap = new HashMap<>();
54+
int total = 0;
55+
for (Map.Entry<String, List<InputPart>> e : ret.getFormDataMap().entrySet()) {
56+
for (InputPart inputPart : e.getValue()) {
57+
String contentType = contentTypeOf(inputPart);
58+
if (!isTextPlain(contentType)) {
59+
continue;
60+
}
61+
if (total >= MAX_FILES_TO_INSPECT) {
62+
return bodyMap;
63+
}
64+
bodyMap
65+
.computeIfAbsent(e.getKey(), k -> new ArrayList<>())
66+
.add(readContent(inputPart, contentTypeWithDefaultUtf8(contentType)));
67+
total++;
68+
}
69+
}
70+
return bodyMap;
71+
}
72+
73+
// No Content-Type header means RESTEasy defaults the part to text/plain (see InputPart's own
74+
// javadoc), so a null contentType is treated as text/plain here as well.
75+
private static boolean isTextPlain(String contentType) {
76+
if (contentType == null) {
77+
return true;
78+
}
79+
int semi = contentType.indexOf(';');
80+
String mediaType = (semi >= 0 ? contentType.substring(0, semi) : contentType).trim();
81+
return mediaType.equalsIgnoreCase("text/plain");
82+
}
83+
84+
// Used for the body-map/text-field path only: matches Jersey's own getValue(), which decodes
85+
// undeclared-charset text parts as UTF-8 instead of falling back to the JVM platform charset
86+
// (MultipartContentDecoder's default for the filesContent path, kept as-is for parity with the
87+
// other multipart integrations).
88+
private static String contentTypeWithDefaultUtf8(String contentType) {
89+
return MultipartContentDecoder.extractCharset(contentType) == null
90+
? (contentType == null ? "charset=UTF-8" : contentType + "; charset=UTF-8")
91+
: contentType;
92+
}
93+
94+
private static String contentTypeOf(InputPart inputPart) {
95+
if (GET_HEADERS == null) {
96+
return null;
97+
}
98+
try {
99+
@SuppressWarnings("unchecked")
100+
Map<String, List<String>> headers = (Map<String, List<String>>) GET_HEADERS.invoke(inputPart);
101+
if (headers == null) {
102+
return null;
103+
}
104+
List<String> ctHeaders = getHeaderCaseInsensitive(headers, "Content-Type");
105+
return (ctHeaders != null && !ctHeaders.isEmpty()) ? ctHeaders.get(0) : null;
106+
} catch (Exception e) {
107+
// Reflective getHeaders() call failed (unexpected InputPart implementation): fall back to
108+
// resolving no content-type rather than aborting the whole request's body-map collection.
109+
log.debug("Failed to read multipart part headers via reflection", e);
110+
return null;
111+
}
112+
}
113+
38114
public static List<String> collectFilenames(MultipartFormDataInput ret) {
39115
List<String> filenames = new ArrayList<>();
40116
if (GET_HEADERS == null) {
@@ -121,7 +197,10 @@ static String readContent(InputPart inputPart, String contentType) {
121197
try (InputStream is = inputPart.getBody(InputStream.class, null)) {
122198
if (is == null) return "";
123199
return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, contentType);
124-
} catch (IOException ignored) {
200+
} catch (Exception e) {
201+
// getBody()/readInputStream() can throw unchecked exceptions too (e.g. a MessageBodyReader
202+
// lookup failure); one bad part must not abort the whole request's body/content collection.
203+
log.debug("Failed to read multipart part content, returning empty string", e);
125204
return "";
126205
}
127206
}

0 commit comments

Comments
 (0)