Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,10 @@
import datadog.trace.api.gateway.RequestContext;
import datadog.trace.api.gateway.RequestContextSlot;
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import net.bytebuddy.asm.Advice;
import org.jboss.resteasy.plugins.providers.multipart.InputPart;
import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput;

@AutoService(InstrumenterModule.class)
Expand Down Expand Up @@ -67,8 +63,7 @@ public static class ReadFromAdvice {
static void after(
@Advice.Return final MultipartFormDataInput ret,
@ActiveRequestContext RequestContext reqCtx,
@Advice.Thrown(readOnly = false) Throwable t)
throws IOException {
@Advice.Thrown(readOnly = false) Throwable t) {
if (ret == null || t != null) {
return;
}
Expand All @@ -85,14 +80,7 @@ static void after(
}

if (callback != null) {
Map<String, List<String>> m = new HashMap<>();
for (Map.Entry<String, List<InputPart>> e : ret.getFormDataMap().entrySet()) {
List<String> strings = new ArrayList<>();
m.put(e.getKey(), strings);
for (InputPart inputPart : e.getValue()) {
strings.add(inputPart.getBodyAsString());
}
}
Map<String, List<String>> m = MultipartHelper.collectBodyMap(ret);

Flow<Void> flow = callback.apply(reqCtx, m);
BlockingException be =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@
import datadog.trace.api.gateway.Flow;
import datadog.trace.api.gateway.RequestContext;
import datadog.trace.api.http.MultipartContentDecoder;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.jboss.resteasy.plugins.providers.multipart.InputPart;
import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public final class MultipartHelper {

private static final Logger log = LoggerFactory.getLogger(MultipartHelper.class);

public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes();
public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount();

Expand All @@ -35,6 +39,142 @@ private MultipartHelper() {}
GET_HEADERS = m;
}

/**
* Builds the {@code server.request.body} map out of the multipart parts.
*
* <p>Only text/plain parts with no {@code filename} attribute are collected, matching the intent
* of the Jersey reference: file parts (including a file declared with a {@code text/plain}
* content-type) are reported separately via {@link #collectFilenames} / {@link
* #collectFilesContent} and must not also consume the body-map budget, or a request could pad out
* the cap with disposable text-file parts and push a real form field out of the map. The number
* of collected values is capped by {@link #MAX_FILES_TO_INSPECT}. The cap counts the total
* accumulated values across all field names, not the distinct keys: {@code getFormDataMap()}
* already groups parts by field name, so a per-key cap would be trivially bypassed by repeating
* the same field name on every part.
*/
public static Map<String, List<String>> collectBodyMap(MultipartFormDataInput ret) {
Map<String, List<String>> bodyMap = new HashMap<>();
int total = 0;
for (Map.Entry<String, List<InputPart>> e : ret.getFormDataMap().entrySet()) {
for (InputPart inputPart : e.getValue()) {
Map<String, List<String>> headers = headersOf(inputPart);
String contentType = contentTypeOf(headers);
if (!isTextPlain(contentType) || hasFilename(headers)) {
Comment thread
jandro996 marked this conversation as resolved.
continue;
}
if (total >= MAX_FILES_TO_INSPECT) {
return bodyMap;
}
bodyMap
.computeIfAbsent(e.getKey(), k -> new ArrayList<>())
.add(readContent(inputPart, contentTypeWithDefaultUtf8(contentType)));
total++;
}
}
return bodyMap;
}

// No Content-Type header means RESTEasy defaults the part to text/plain (see InputPart's own
// javadoc), so a null contentType is treated as text/plain here as well.
private static boolean isTextPlain(String contentType) {
if (contentType == null) {
return true;
}
int semi = contentType.indexOf(';');
String mediaType = (semi >= 0 ? contentType.substring(0, semi) : contentType).trim();
return mediaType.equalsIgnoreCase("text/plain");
}

// Used for the body-map/text-field path only: matches Jersey's own getValue(), which decodes
// undeclared-charset text parts as UTF-8 instead of falling back to the JVM platform charset
// (MultipartContentDecoder's default for the filesContent path, kept as-is for parity with the
// other multipart integrations).
private static String contentTypeWithDefaultUtf8(String contentType) {
return MultipartContentDecoder.extractCharset(contentType) == null
? (contentType == null ? "charset=UTF-8" : contentType + "; charset=UTF-8")
Comment thread
jandro996 marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include a media type in the UTF-8 fallback

When a text part has no Content-Type, this branch produces charset=UTF-8, but the rewritten extractCharset skips everything before the first ; or , as the media type and therefore returns null for that string. decodeBytes consequently falls back to Charset.defaultCharset(), so on JDKs or locales whose platform charset is not UTF-8, headerless UTF-8 form values are misdecoded before AppSec inspection. Return a valid value such as text/plain; charset=UTF-8, or explicitly support parameter-only input in the decoder.

Useful? React with 👍 / 👎.

: contentType;
Comment thread
jandro996 marked this conversation as resolved.
Comment thread
jandro996 marked this conversation as resolved.
Comment thread
jandro996 marked this conversation as resolved.
}

// Used by collectBodyMap only: collectFilenames/collectFilesContent do their own reflective
// getHeaders() call and are intentionally left untouched (out of scope, already correct).
private static Map<String, List<String>> headersOf(InputPart inputPart) {
if (GET_HEADERS == null) {
return null;
}
try {
@SuppressWarnings("unchecked")
Map<String, List<String>> headers = (Map<String, List<String>>) GET_HEADERS.invoke(inputPart);
return headers;
} catch (Exception e) {
// Reflective getHeaders() call failed (unexpected InputPart implementation): fall back to
// resolving no headers rather than aborting the whole request's body-map collection.
log.debug("Failed to read multipart part headers via reflection", e);
return null;
}
}

private static String contentTypeOf(Map<String, List<String>> headers) {
if (headers == null) {
return null;
}
List<String> ctHeaders = getHeaderCaseInsensitive(headers, "Content-Type");
return (ctHeaders != null && !ctHeaders.isEmpty()) ? ctHeaders.get(0) : null;
}

// A part with a filename attribute (present, even if empty) is a file upload, not a form field,
// regardless of its declared content-type: a file can be declared text/plain and would otherwise
// pass isTextPlain() and consume the body-map budget meant for genuine form fields. Uses
// hasFilenameParam() rather than rawFilenameFromContentDisposition(), which deliberately ignores
// the RFC 5987 "filename*" form: a part carrying only "filename*" must still be excluded here.
// collectFilesContent() uses the same hasFilenameParam() gate, so such a part is still inspected
// there even though this file never decodes its filename* value.
private static boolean hasFilename(Map<String, List<String>> headers) {
if (headers == null) {
return false;
}
List<String> cdHeaders = getHeaderCaseInsensitive(headers, "Content-Disposition");
if (cdHeaders == null || cdHeaders.isEmpty()) {
return false;
}
return hasFilenameParam(cdHeaders.get(0));
}

// Presence-only counterpart of rawFilenameFromContentDisposition(): recognizes both the plain
// "filename" parameter and the RFC 5987 extended "filename*" form (e.g. filename*=UTF-8''a.txt),
// since either form marks the part as a file upload. Unlike rawFilenameFromContentDisposition(),
// this never needs to decode the value, so the RFC 5987 charset/percent-encoding is irrelevant
// here. Shares the same quote-aware semicolon scanning; see rawFilenameFromContentDisposition()
// for the rationale.
private static boolean hasFilenameParam(String cd) {
if (cd == null) return false;
int i = 0;
int len = cd.length();
while (i < len) {
while (i < len && cd.charAt(i) != ';') {
if (cd.charAt(i) == '"') {
i++;
while (i < len && cd.charAt(i) != '"') {
if (cd.charAt(i) == '\\') i++;
i++;
}
}
i++;
}
if (i >= len) break;
i++;
while (i < len && (cd.charAt(i) == ' ' || cd.charAt(i) == '\t')) i++;
if (cd.regionMatches(true, i, "filename", 0, 8)) {
int j = i + 8;
if (j < len && cd.charAt(j) == '*') j++;
while (j < len && (cd.charAt(j) == ' ' || cd.charAt(j) == '\t')) j++;
if (j < len && cd.charAt(j) == '=') {
return true;
}
}
}
return false;
}

public static List<String> collectFilenames(MultipartFormDataInput ret) {
List<String> filenames = new ArrayList<>();
if (GET_HEADERS == null) {
Expand Down Expand Up @@ -89,9 +229,9 @@ public static List<String> collectFilesContent(MultipartFormDataInput ret) {
if (cdHeaders == null || cdHeaders.isEmpty()) {
continue;
}
// rawFilenameFromContentDisposition returns null if filename attr absent,
// otherwise returns the value (possibly empty) — both cases warrant content inspection
if (rawFilenameFromContentDisposition(cdHeaders.get(0)) == null) {
// hasFilenameParam recognizes both filename and filename* (see its javadoc): a part with
// neither is a plain form field, already covered by collectBodyMap, and skipped here.
if (!hasFilenameParam(cdHeaders.get(0))) {
continue;
}
List<String> ctHeaders = getHeaderCaseInsensitive(headers, "Content-Type");
Expand Down Expand Up @@ -121,7 +261,10 @@ static String readContent(InputPart inputPart, String contentType) {
try (InputStream is = inputPart.getBody(InputStream.class, null)) {
if (is == null) return "";
return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, contentType);
} catch (IOException ignored) {
} catch (Exception e) {
// getBody()/readInputStream() can throw unchecked exceptions too (e.g. a MessageBodyReader
// lookup failure); one bad part must not abort the whole request's body/content collection.
log.debug("Failed to read multipart part content, returning empty string", e);
return "";
}
}
Expand Down
Loading