Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c68f611
Add server.request.body.filenames support for Undertow and Play
jandro996 Mar 23, 2026
38a0f14
Fix server.request.body.filenames for in-memory uploads in Undertow 2.2
jandro996 Apr 21, 2026
719caa1
Decouple requestBodyProcessed and requestFilesFilenames callbacks in …
jandro996 Apr 21, 2026
084e7df
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-4-undertow…
jandro996 Apr 22, 2026
845e251
Fix Scala 2.13 muzzle incompatibility in Play multipart filenames sup…
jandro996 Apr 22, 2026
82f18fd
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-4-undertow…
jandro996 Apr 23, 2026
e977265
Skip filenames WAF callback when body callback already caused a block
jandro996 Apr 23, 2026
3e61264
Add unit tests for FormDataMap and BodyParserHelpers.jsValueToJavaObject
jandro996 Apr 23, 2026
6cb80d4
Fix tryCommitBlockingResponse return-value check; simplify appsec gua…
jandro996 Apr 23, 2026
b3a4e2a
Align play-appsec-2.6 handleMultipartFilenames with play-appsec-2.5 s…
jandro996 Apr 23, 2026
c4da39c
Cache MultipartFormData.files() Method to avoid per-request reflectio…
jandro996 Apr 23, 2026
a15be2f
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-4-undertow…
jandro996 Apr 23, 2026
d9c8ab6
Fix FormDataMapTest anonymous FormValue missing undertow 2.2.x methods
jandro996 Apr 23, 2026
2fda53f
Use Proxy in FormDataMapTest to handle undertow 2.0/2.2 interface dif…
jandro996 Apr 23, 2026
43dc2fc
Move testBodyFilenames from AbstractPlayServerTest to play-appsec-2.6…
jandro996 Apr 23, 2026
3e9621e
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-4-undertow…
jandro996 Apr 24, 2026
28d6b11
Fix dual-fire rule and BlockingException propagation clarity in Play …
jandro996 Apr 28, 2026
5a2718b
Make filenames blocking guard consistent with body blocking guard in …
jandro996 Apr 28, 2026
eb232bd
Fix collectFilenames to accept java.util.Iterator to enable unit testing
jandro996 Apr 28, 2026
59ef52b
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-4-undertow…
jandro996 Apr 28, 2026
9d8b89f
Replace anonymous iterator with named ScalaIteratorAdapter; add to he…
jandro996 Apr 28, 2026
eca21f3
Guard filenames tryCommitBlockingResponse if body already blocked in …
jandro996 Apr 28, 2026
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 @@ -19,6 +19,7 @@
import datadog.trace.api.gateway.RequestContextSlot;
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -47,6 +48,21 @@ public class BodyParserHelpers {
private static final Logger log = LoggerFactory.getLogger(BodyParserHelpers.class);
public static final int MAX_RECURSION = 15;

// Cached via reflection to avoid embedding a hard binary reference to
// files():Lscala/collection/Seq; — the return type changed to
// Lscala/collection/immutable/Seq; in Scala 2.13 (Play 2.7+), which would
// cause muzzle to disable the instrumentation for Play 2.7.
private static final Method MULTIPART_FILES_METHOD;

static {
Method m = null;
try {
m = MultipartFormData.class.getMethod("files");
} catch (Exception ignored) {
}
MULTIPART_FILES_METHOD = m;
}

private static JFunction1<
scala.collection.immutable.Map<String, Seq<String>>,
scala.collection.immutable.Map<String, Seq<String>>>
Expand Down Expand Up @@ -105,20 +121,91 @@ private static String handleText(String s) {

private static MultipartFormData<?> handleMultipartFormData(MultipartFormData<?> data) {
scala.collection.immutable.Map<String, Seq<String>> mpfd = data.asFormUrlEncoded();

if (mpfd == null || mpfd.isEmpty()) {
return data;
BlockingException pendingBlock = null;

if (mpfd != null && !mpfd.isEmpty()) {
try {
Object conv = tryConvertingScalaContainers(mpfd, MAX_CONVERSION_DEPTH);
handleArbitraryPostData(conv, "multipartFormData");
} catch (BlockingException be) {
pendingBlock = be;
} catch (Exception e) {
log.debug("Error handling result of multipartFormData BodyParser", e);
}
}

try {
Object conv = tryConvertingScalaContainers(mpfd, MAX_CONVERSION_DEPTH);
handleArbitraryPostData(conv, "multipartFormData");
if (MULTIPART_FILES_METHOD != null) {
Object files = MULTIPART_FILES_METHOD.invoke(data);
if (files instanceof scala.collection.Iterable) {
handleMultipartFilenames(
new ScalaIteratorAdapter(((scala.collection.Iterable<?>) files).iterator()));
}
}
} catch (BlockingException be) {
if (pendingBlock == null) pendingBlock = be;
} catch (Exception e) {
handleException(e, "Error handling result of multipartFormData BodyParser");
log.debug("Error handling multipartFormData filenames", e);
}

if (pendingBlock != null) throw pendingBlock;
return data;
}

private static void handleMultipartFilenames(java.util.Iterator<?> iterator) {
AgentSpan span = activeSpan();
if (span == null) {
return;
}
RequestContext reqCtx = span.getRequestContext();
if (reqCtx == null || reqCtx.getData(RequestContextSlot.APPSEC) == null) {
return;
}

List<String> filenames = collectFilenames(iterator);
if (filenames.isEmpty()) {
return;
}

CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC);
BiFunction<RequestContext, List<String>, Flow<Void>> callback =
cbp.getCallback(EVENTS.requestFilesFilenames());
if (callback == null) {
return;
}
executeFilenamesCallback(reqCtx, callback, filenames);
}

static List<String> collectFilenames(java.util.Iterator<?> iterator) {
List<String> filenames = new ArrayList<>();
while (iterator.hasNext()) {
MultipartFormData.FilePart<?> part = (MultipartFormData.FilePart<?>) iterator.next();
String filename = part.filename();
if (filename != null && !filename.isEmpty()) {
filenames.add(filename);
}
}
return filenames;
}

private static void executeFilenamesCallback(
RequestContext reqCtx,
BiFunction<RequestContext, List<String>, Flow<Void>> callback,
List<String> filenames) {
Flow<Void> flow = callback.apply(reqCtx, filenames);
Flow.Action action = flow.getAction();
if (action instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
if (brf != null) {
boolean success = brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (success) {
throw new BlockingException("Blocked request (multipart file upload)");
}
}
}
}

public static Function1<JsValue, JsValue> getHandleJsonF() {
return HANDLE_JSON;
}
Expand Down Expand Up @@ -302,4 +389,22 @@ private static Object jsNodeToJavaObject(JsonNode value, int maxRecursion) {
return value.asText("");
}
}

static final class ScalaIteratorAdapter implements java.util.Iterator<Object> {
private final Iterator<?> delegate;

ScalaIteratorAdapter(Iterator<?> delegate) {
this.delegate = delegate;
}

@Override
public boolean hasNext() {
return delegate.hasNext();
}

@Override
public Object next() {
return delegate.next();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public String instrumentedType() {
@Override
public String[] helperClassNames() {
return new String[] {
packageName + ".BodyParserHelpers",
packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public String[] knownMatchingTypes() {
@Override
public String[] helperClassNames() {
return new String[] {
packageName + ".BodyParserHelpers",
packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public String instrumentedType() {
@Override
public String[] helperClassNames() {
return new String[] {
packageName + ".BodyParserHelpers",
packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public String instrumentedType() {
@Override
public String[] helperClassNames() {
return new String[] {
packageName + ".BodyParserHelpers",
packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public String instrumentedType() {
@Override
public String[] helperClassNames() {
return new String[] {
packageName + ".BodyParserHelpers",
packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ class PlayServerTest extends HttpServerTest<Server> {
true
}

@Override
boolean testBodyFilenames() {
true
}

@Override
boolean testBlocking() {
true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package datadog.trace.instrumentation.play25.appsec;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import play.api.libs.json.JsValue;
import play.api.mvc.MultipartFormData;

class BodyParserHelpersTest {

private static JsValue parse(String json) {
return play.api.libs.json.Json$.MODULE$.parse(json);
}

@Test
void jsValueToJavaObject_nullInputReturnsNull() {
assertNull(BodyParserHelpers.jsValueToJavaObject(null));
}

@Test
void jsValueToJavaObject_jsNullReturnsNull() {
assertNull(BodyParserHelpers.jsValueToJavaObject(parse("null")));
}

@Test
void jsValueToJavaObject_string() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("\"hello\""));
assertEquals("hello", result);
}

@Test
void jsValueToJavaObject_number() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("42"));
assertTrue(result instanceof BigDecimal);
assertEquals(0, ((BigDecimal) result).compareTo(new BigDecimal("42")));
}

@Test
void jsValueToJavaObject_booleanTrue() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("true"));
assertEquals(Boolean.TRUE, result);
}

@Test
void jsValueToJavaObject_booleanFalse() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("false"));
assertEquals(Boolean.FALSE, result);
}

@Test
@SuppressWarnings("unchecked")
void jsValueToJavaObject_object() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"key\":\"value\",\"num\":1}"));
assertTrue(result instanceof Map);
Map<String, Object> map = (Map<String, Object>) result;
assertEquals("value", map.get("key"));
assertTrue(map.get("num") instanceof BigDecimal);
}

@Test
@SuppressWarnings("unchecked")
void jsValueToJavaObject_array() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("[\"a\",\"b\",\"c\"]"));
assertTrue(result instanceof List);
List<Object> list = (List<Object>) result;
assertEquals(3, list.size());
assertEquals("a", list.get(0));
assertEquals("b", list.get(1));
assertEquals("c", list.get(2));
}

@Test
@SuppressWarnings("unchecked")
void jsValueToJavaObject_nestedObject() {
Object result =
BodyParserHelpers.jsValueToJavaObject(parse("{\"outer\":{\"inner\":\"deep\"}}"));
assertTrue(result instanceof Map);
Map<String, Object> outer = (Map<String, Object>) result;
assertTrue(outer.get("outer") instanceof Map);
Map<String, Object> inner = (Map<String, Object>) outer.get("outer");
assertEquals("deep", inner.get("inner"));
}

@Test
void jsValueToJavaObject_zeroRecursionReturnsNull() {
Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"key\":\"value\"}"), 0);
assertNull(result);
}

@Test
@SuppressWarnings("unchecked")
void jsValueToJavaObject_recursionLimitTruncatesNesting() {
// depth=1: outer object is converted, but children exceed the limit and become null
Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"a\":{\"b\":\"val\"}}"), 1);
assertTrue(result instanceof Map);
Map<String, Object> map = (Map<String, Object>) result;
assertNull(map.get("a"));
}

// --- collectFilenames tests ---

@Test
void collectFilenames_emptyIterator() {
List<String> result = BodyParserHelpers.collectFilenames(Collections.emptyIterator());
assertTrue(result.isEmpty());
}

@Test
void collectFilenames_nullFilenameExcluded() throws Exception {
List<String> result =
BodyParserHelpers.collectFilenames(
Collections.<Object>singletonList(filePart("f", null)).iterator());
assertTrue(result.isEmpty());
}

@Test
void collectFilenames_emptyFilenameExcluded() throws Exception {
List<String> result =
BodyParserHelpers.collectFilenames(
Collections.<Object>singletonList(filePart("f", "")).iterator());
assertTrue(result.isEmpty());
}

@Test
void collectFilenames_validFilenameIncluded() throws Exception {
List<String> result =
BodyParserHelpers.collectFilenames(
Collections.<Object>singletonList(filePart("f", "evil.php")).iterator());
assertEquals(Collections.singletonList("evil.php"), result);
}

@Test
void collectFilenames_mixedPartsFiltered() throws Exception {
List<Object> parts =
Arrays.<Object>asList(
filePart("f1", "a.pdf"),
filePart("f2", null),
filePart("f3", ""),
filePart("f4", "b.jpg"));
List<String> result = BodyParserHelpers.collectFilenames(parts.iterator());
assertEquals(Arrays.asList("a.pdf", "b.jpg"), result);
}

@SuppressWarnings("unchecked")
private static MultipartFormData.FilePart<Object> filePart(String key, String filename)
throws Exception {
// FilePart is a Scala case class nested in object MultipartFormData.
// Use the companion object's apply() to avoid JVM inner-class constructor complexity.
Class<?> companionClass = Class.forName("play.api.mvc.MultipartFormData$FilePart$");
Object companion = companionClass.getField("MODULE$").get(null);
for (Method m : companionClass.getMethods()) {
if ("apply".equals(m.getName()) && m.getParameterCount() == 4) {
return (MultipartFormData.FilePart<Object>)
m.invoke(companion, key, filename, scala.None$.MODULE$, new Object());
}
}
throw new IllegalStateException("FilePart.apply(4 params) not found");
}
}
Loading
Loading