Skip to content
Draft
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
629f074
Instrument Jetty for server.request.body.filenames
jandro996 Mar 23, 2026
b276e16
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 6, 2026
b1ec26b
Fix GetFilenamesAdvice double-firing and extend coverage to getParts(…
jandro996 Apr 6, 2026
d8a92f8
spotless
jandro996 Apr 7, 2026
4d8ca56
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 8, 2026
30bb769
Extend testBodyFilenamesCalledOnce coverage to Jetty 9.x and 10.x
jandro996 Apr 7, 2026
abddcfa
Add BODY_MULTIPART_COMBINED test to cover GetFilenamesFromMultiPartAd…
jandro996 Apr 8, 2026
eeab933
spotless
jandro996 Apr 8, 2026
0040e75
Fix missing static imports for BODY_MULTIPART_REPEATED and BODY_MULTI…
jandro996 Apr 8, 2026
c5268dd
Fix GetFilenamesAdvice double-fire for Jetty 9.4+ where _multiParts r…
jandro996 Apr 8, 2026
db08e43
Fix GetFilenamesAdvice double-fire in jetty-appsec-8.1.3
jandro996 Apr 8, 2026
30e4b8c
Fix GetFilenamesAdvice double-fire for all Jetty 9.3–11 versions
jandro996 Apr 8, 2026
3fd02e3
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 9, 2026
7f391b4
Simplify GetFilenamesAdvice in jetty-appsec-8.1.3: remove dead Servle…
jandro996 Apr 9, 2026
246f4e3
Remove unnecessary casts in Jetty AppSec GetFilenamesAdvice
jandro996 Apr 9, 2026
20f8bb4
Extract Content-Disposition parsing to MultipartHelper + unit tests
jandro996 Apr 9, 2026
398ea51
Extract filename extraction to MultipartHelper in jetty-appsec-9.2 + …
jandro996 Apr 9, 2026
52c5cb8
Split jetty-appsec-9.3 [9.3,12) into three clean modules: 9.3, 9.4, 11.0
jandro996 Apr 10, 2026
182ce98
Add Jetty 8.x integration tests for multipart body and filenames
jandro996 Apr 10, 2026
b9f5a74
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 10, 2026
d92ff94
Fix muzzle range for jetty-appsec-9.3/9.4: split at 9.4.10
jandro996 Apr 10, 2026
eb6ca70
Replace MultiPartsFieldMatcher with typed module splits for Jetty 9.4…
jandro996 Apr 10, 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 @@ -65,6 +65,8 @@ import java.util.function.Supplier

import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_JSON
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_MULTIPART
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_MULTIPART_COMBINED
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_MULTIPART_REPEATED
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_URLENCODED
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.CREATED
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.CREATED_IS
Expand Down Expand Up @@ -368,6 +370,14 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
false
}

boolean testBodyFilenamesCalledOnce() {
false
}

boolean testBodyFilenamesCalledOnceCombined() {
false
}

boolean testBodyFilenames() {
false
}
Expand Down Expand Up @@ -476,6 +486,8 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
CREATED_IS("created_input_stream", 201, "created"),
BODY_URLENCODED("body-urlencoded?ignore=pair", 200, '[a:[x]]'),
BODY_MULTIPART("body-multipart?ignore=pair", 200, '[a:[x]]'),
BODY_MULTIPART_REPEATED("body-multipart-repeated", 200, "ok"),
BODY_MULTIPART_COMBINED("body-multipart-combined", 200, "ok"),
BODY_JSON("body-json", 200, '{"a":"x"}'),
BODY_XML("body-xml", 200, '<foo attr="attr_value">mytext<bar/></foo>'),
REDIRECT("redirect", 302, "/redirected"),
Expand Down Expand Up @@ -1646,6 +1658,54 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
response.close()
}

def 'test instrumentation gateway file upload filenames called once'() {
setup:
assumeTrue(testBodyFilenamesCalledOnce())
RequestBody fileBody = RequestBody.create(MediaType.parse('application/octet-stream'), 'file content')
def body = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart('file', 'evil.php', fileBody)
.build()
def httpRequest = request(BODY_MULTIPART_REPEATED, 'POST', body).build()
def response = client.newCall(httpRequest).execute()

when:
TEST_WRITER.waitForTraces(1)

then:
TEST_WRITER.get(0).any {
it.getTag('request.body.filenames') == "[evil.php]"
&& it.getTag('_dd.appsec.filenames.cb.calls') == 1
}

cleanup:
response.close()
}

def 'test instrumentation gateway file upload filenames called once via parameter map'() {
setup:
assumeTrue(testBodyFilenamesCalledOnceCombined())
RequestBody fileBody = RequestBody.create(MediaType.parse('application/octet-stream'), 'file content')
def body = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart('file', 'evil.php', fileBody)
.build()
def httpRequest = request(BODY_MULTIPART_COMBINED, 'POST', body).build()
def response = client.newCall(httpRequest).execute()

when:
TEST_WRITER.waitForTraces(1)

then:
TEST_WRITER.get(0).any {
it.getTag('request.body.filenames') == "[evil.php]"
&& it.getTag('_dd.appsec.filenames.cb.calls') == 1
}

cleanup:
response.close()
}

def 'test instrumentation gateway json request body'() {
setup:
assumeTrue(testBodyJson())
Expand Down Expand Up @@ -2581,6 +2641,7 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
boolean responseBodyTag
Object responseBody
List<String> uploadedFilenames
int uploadedFilenamesCallCount = 0
}

static final String stringOrEmpty(String string) {
Expand Down Expand Up @@ -2754,6 +2815,8 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
rqCtxt.traceSegment.setTagTop('request.body.filenames', filenames as String)
Context context = rqCtxt.getData(RequestContextSlot.APPSEC)
context.uploadedFilenames = filenames
context.uploadedFilenamesCallCount++
rqCtxt.traceSegment.setTagTop('_dd.appsec.filenames.cb.calls', context.uploadedFilenamesCallCount)
Flow.ResultFlow.empty()
} as BiFunction<RequestContext, List<String>, Flow<Void>>)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
muzzle {
pass {
group = 'org.eclipse.jetty'
module = 'jetty-server'
versions = '[10.0.10,11.0)'
assertInverse = true
javaVersion = 11
}
}

apply from: "$rootDir/gradle/java.gradle"

dependencies {
compileOnly(group: 'org.eclipse.jetty', name: 'jetty-server', version: '10.0.26') {
exclude group: 'org.slf4j', module: 'slf4j-api'
}
}

tasks.withType(JavaCompile).configureEach {
configureCompiler(it, 11, JavaVersion.VERSION_1_8)
}

// testing happens in the jetty-* modules
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package datadog.trace.instrumentation.jetty1010;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import javax.servlet.http.Part;

public class MultipartHelper {

private MultipartHelper() {}

/**
* Extracts non-null, non-empty filenames from a collection of multipart {@link Part}s using
* {@link Part#getSubmittedFileName()} (Servlet 3.1+, Jetty 10.0.10+).
*
* @return list of filenames; never {@code null}, may be empty
*/
public static List<String> extractFilenames(Collection<Part> parts) {
if (parts == null || parts.isEmpty()) {
return Collections.emptyList();
}
List<String> filenames = new ArrayList<>();
for (Part part : parts) {
String name = part.getSubmittedFileName();
if (name != null && !name.isEmpty()) {
filenames.add(name);
}
}
return filenames;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package datadog.trace.instrumentation.jetty1010;

import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
import static datadog.trace.api.gateway.Events.EVENTS;
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;

import com.google.auto.service.AutoService;
import datadog.appsec.api.blocking.BlockingException;
import datadog.trace.advice.ActiveRequestContext;
import datadog.trace.advice.RequiresRequestContext;
import datadog.trace.agent.tooling.Instrumenter;
import datadog.trace.agent.tooling.InstrumenterModule;
import datadog.trace.agent.tooling.muzzle.Reference;
import datadog.trace.api.gateway.BlockResponseFunction;
import datadog.trace.api.gateway.CallbackProvider;
import datadog.trace.api.gateway.Flow;
import datadog.trace.api.gateway.RequestContext;
import datadog.trace.api.gateway.RequestContextSlot;
import datadog.trace.bootstrap.CallDepthThreadLocalMap;
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
import java.util.Collection;
import java.util.List;
import java.util.function.BiFunction;
import javax.servlet.http.Part;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.implementation.bytecode.assign.Assigner;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.util.MultiMap;

@AutoService(InstrumenterModule.class)
public class RequestExtractContentParametersInstrumentation extends InstrumenterModule.AppSec
implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice {
private static final String MULTI_MAP_INTERNAL_NAME = "Lorg/eclipse/jetty/util/MultiMap;";

public RequestExtractContentParametersInstrumentation() {
super("jetty");
}

@Override
public String instrumentedType() {
return "org.eclipse.jetty.server.Request";
}

@Override
public String[] helperClassNames() {
return new String[] {packageName + ".MultipartHelper"};
}

@Override
public void methodAdvice(MethodTransformer transformer) {
transformer.applyAdvice(
named("extractContentParameters").and(takesArguments(0)).or(named("getParts")),
getClass().getName() + "$ExtractContentParametersAdvice");
transformer.applyAdvice(
named("getParts").and(takesArguments(0)), getClass().getName() + "$GetFilenamesAdvice");
transformer.applyAdvice(
named("getParts").and(takesArguments(1)),
getClass().getName() + "$GetFilenamesFromMultiPartAdvice");
}

// Discriminates Jetty 10.0.10–10.0.x ([10.0.10, 11.0)):
// - _contentParameters + extractContentParameters(void) exist from 9.3+ (excludes 9.2)
// - _multiParts: MultiParts exists in 10.0.10+ (excludes 10.0.0–10.0.9 where it was
// MultiPartFormInputStream, covered by jetty-appsec-10.0)
// - _queryEncoding: Charset exists in all 10.x (excludes 9.4.x where it is String, which also
// has _multiParts: MultiParts from 9.4.10+)
// - javax.servlet.http.Part exists in 10.x classpath (excludes Jetty 11+ which uses jakarta)
private static final Reference REQUEST_REFERENCE =
new Reference.Builder("org.eclipse.jetty.server.Request")
.withMethod(new String[0], 0, "extractContentParameters", "V")
.withField(new String[0], 0, "_contentParameters", MULTI_MAP_INTERNAL_NAME)
.withField(new String[0], 0, "_multiParts", "Lorg/eclipse/jetty/server/MultiParts;")
.withField(new String[0], 0, "_queryEncoding", "Ljava/nio/charset/Charset;")
.build();

private static final Reference JAVAX_PART_REFERENCE =
new Reference.Builder("javax.servlet.http.Part").build();

@Override
public Reference[] additionalMuzzleReferences() {
return new Reference[] {REQUEST_REFERENCE, JAVAX_PART_REFERENCE};
}

@RequiresRequestContext(RequestContextSlot.APPSEC)
public static class ExtractContentParametersAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
static boolean before(@Advice.FieldValue("_contentParameters") final MultiMap<String> map) {
final int callDepth = CallDepthThreadLocalMap.incrementCallDepth(Request.class);
return callDepth == 0 && map == null;
}

@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
static void after(
@Advice.Enter boolean proceed,
@Advice.FieldValue("_contentParameters") final MultiMap<String> map,
@ActiveRequestContext RequestContext reqCtx,
@Advice.Thrown(readOnly = false) Throwable t) {
CallDepthThreadLocalMap.decrementCallDepth(Request.class);
if (!proceed) {
return;
}
if (map == null || map.isEmpty()) {
return;
}

CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC);
BiFunction<RequestContext, Object, Flow<Void>> callback =
cbp.getCallback(EVENTS.requestBodyProcessed());
if (callback == null) {
return;
}

Flow<Void> flow = callback.apply(reqCtx, map);
Flow.Action action = flow.getAction();
if (action instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction();
if (blockResponseFunction != null) {
blockResponseFunction.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (t == null) {
t = new BlockingException("Blocked request (for Request/extractContentParameters)");
reqCtx.getTraceSegment().effectivelyBlocked();
}
}
}
}
}

/**
* Fires the {@code requestFilesFilenames} event when the application calls public {@code
* getParts()}. Guards prevent double-firing:
*
* <ul>
* <li>{@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code
* getParameterMap()} path); means filenames were already reported via {@code
* GetFilenamesFromMultiPartAdvice}.
* <li>{@code _multiParts != null}: set by the first {@code getParts()} call in Jetty 10.0.10+;
* means filenames were already reported.
* </ul>
*/
@RequiresRequestContext(RequestContextSlot.APPSEC)
public static class GetFilenamesAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
static boolean before(
@Advice.FieldValue("_contentParameters") final MultiMap<String> contentParameters,
@Advice.FieldValue(value = "_multiParts", typing = Assigner.Typing.DYNAMIC)
final Object multiParts) {
final int callDepth = CallDepthThreadLocalMap.incrementCallDepth(Collection.class);
return callDepth == 0 && contentParameters == null && multiParts == null;
}

@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
static void after(
@Advice.Enter boolean proceed,
@Advice.Return Collection<Part> parts,
@ActiveRequestContext RequestContext reqCtx,
@Advice.Thrown(readOnly = false) Throwable t) {
CallDepthThreadLocalMap.decrementCallDepth(Collection.class);
if (!proceed || t != null || parts == null || parts.isEmpty()) {
return;
}
List<String> filenames = MultipartHelper.extractFilenames(parts);
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;
}
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) {
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (t == null) {
t = new BlockingException("Blocked request (multipart file upload)");
reqCtx.getTraceSegment().effectivelyBlocked();
}
}
}
}
}

/**
* Fires the {@code requestFilesFilenames} event when multipart content is parsed via the internal
* {@code getParts(MultiMap)} path triggered by {@code getParameter*()} / {@code
* getParameterMap()} — i.e. when the application never calls public {@code getParts()}.
*/
@RequiresRequestContext(RequestContextSlot.APPSEC)
public static class GetFilenamesFromMultiPartAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
static boolean before() {
return CallDepthThreadLocalMap.incrementCallDepth(Collection.class) == 0;
}

@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
static void after(
@Advice.Enter boolean proceed,
@Advice.Return Collection<Part> parts,
@ActiveRequestContext RequestContext reqCtx,
@Advice.Thrown(readOnly = false) Throwable t) {
CallDepthThreadLocalMap.decrementCallDepth(Collection.class);
if (!proceed || t != null || parts == null || parts.isEmpty()) {
return;
}
List<String> filenames = MultipartHelper.extractFilenames(parts);
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;
}
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) {
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (t == null) {
t = new BlockingException("Blocked request (multipart file upload)");
reqCtx.getTraceSegment().effectivelyBlocked();
}
}
}
}
}
}
Loading
Loading