diff --git a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy index 0b2a28d954b..a599a987cbc 100644 --- a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy +++ b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy @@ -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 @@ -368,6 +370,14 @@ abstract class HttpServerTest extends WithHttpServer { false } + boolean testBodyFilenamesCalledOnce() { + false + } + + boolean testBodyFilenamesCalledOnceCombined() { + false + } + boolean testBodyFilenames() { false } @@ -476,6 +486,8 @@ abstract class HttpServerTest extends WithHttpServer { 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, 'mytext'), REDIRECT("redirect", 302, "/redirected"), @@ -1646,6 +1658,54 @@ abstract class HttpServerTest extends WithHttpServer { 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()) @@ -2581,6 +2641,7 @@ abstract class HttpServerTest extends WithHttpServer { boolean responseBodyTag Object responseBody List uploadedFilenames + int uploadedFilenamesCallCount = 0 } static final String stringOrEmpty(String string) { @@ -2754,6 +2815,8 @@ abstract class HttpServerTest extends WithHttpServer { 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, Flow>) diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/build.gradle new file mode 100644 index 00000000000..544f426941b --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/build.gradle @@ -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 diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/src/main/java/datadog/trace/instrumentation/jetty1010/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/src/main/java/datadog/trace/instrumentation/jetty1010/MultipartHelper.java new file mode 100644 index 00000000000..7ae6899369e --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/src/main/java/datadog/trace/instrumentation/jetty1010/MultipartHelper.java @@ -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 extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/src/main/java/datadog/trace/instrumentation/jetty1010/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/src/main/java/datadog/trace/instrumentation/jetty1010/RequestExtractContentParametersInstrumentation.java new file mode 100644 index 00000000000..77fac590b00 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0.10/src/main/java/datadog/trace/instrumentation/jetty1010/RequestExtractContentParametersInstrumentation.java @@ -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 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 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> callback = + cbp.getCallback(EVENTS.requestBodyProcessed()); + if (callback == null) { + return; + } + + Flow 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: + * + *
    + *
  • {@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code + * getParameterMap()} path); means filenames were already reported via {@code + * GetFilenamesFromMultiPartAdvice}. + *
  • {@code _multiParts != null}: set by the first {@code getParts()} call in Jetty 10.0.10+; + * means filenames were already reported. + *
+ */ + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue("_contentParameters") final MultiMap 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/build.gradle new file mode 100644 index 00000000000..e9d4c3c343c --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/build.gradle @@ -0,0 +1,23 @@ +muzzle { + pass { + group = 'org.eclipse.jetty' + module = 'jetty-server' + versions = '[10.0,10.0.10)' + assertInverse = true + javaVersion = 11 + } +} + +apply from: "$rootDir/gradle/java.gradle" + +dependencies { + compileOnly(group: 'org.eclipse.jetty', name: 'jetty-server', version: '10.0.0') { + exclude group: 'org.slf4j', module: 'slf4j-api' + } +} + +tasks.withType(JavaCompile).configureEach { + configureCompiler(it, 11, JavaVersion.VERSION_1_8) +} + +// testing happens in the jetty-* modules diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/src/main/java/datadog/trace/instrumentation/jetty10/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/src/main/java/datadog/trace/instrumentation/jetty10/MultipartHelper.java new file mode 100644 index 00000000000..2d49f687acf --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/src/main/java/datadog/trace/instrumentation/jetty10/MultipartHelper.java @@ -0,0 +1,32 @@ +package datadog.trace.instrumentation.jetty10; + +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.x). + * + * @return list of filenames; never {@code null}, may be empty + */ + public static List extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/src/main/java/datadog/trace/instrumentation/jetty10/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/src/main/java/datadog/trace/instrumentation/jetty10/RequestExtractContentParametersInstrumentation.java new file mode 100644 index 00000000000..a8788debabc --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-10.0/src/main/java/datadog/trace/instrumentation/jetty10/RequestExtractContentParametersInstrumentation.java @@ -0,0 +1,236 @@ +package datadog.trace.instrumentation.jetty10; + +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.0–10.0.9 ([10.0, 10.0.10)): + // - _contentParameters + extractContentParameters(void) exist from 9.3+ (excludes 9.2) + // - _multiParts: MultiPartFormInputStream exists in 10.0.0–10.0.9 (excludes 9.4.x where it is + // MultiParts, and excludes 10.0.10+ where it reverted to MultiParts) + // - 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/MultiPartFormInputStream;") + .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 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 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> callback = + cbp.getCallback(EVENTS.requestBodyProcessed()); + if (callback == null) { + return; + } + + Flow 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: + * + *
    + *
  • {@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code + * getParameterMap()} path); means filenames were already reported via {@code + * GetFilenamesFromMultiPartAdvice}. + *
  • {@code _multiParts != null}: set by the first {@code getParts()} call in Jetty 10.x; + * means filenames were already reported. + *
+ */ + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue("_contentParameters") final MultiMap 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/build.gradle new file mode 100644 index 00000000000..8e4f1ec487f --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/build.gradle @@ -0,0 +1,24 @@ +muzzle { + pass { + group = 'org.eclipse.jetty' + module = 'jetty-server' + versions = '[11.0.10,12.0)' + assertInverse = true + javaVersion = 11 + } +} + +apply from: "$rootDir/gradle/java.gradle" + +dependencies { + compileOnly(group: 'org.eclipse.jetty', name: 'jetty-server', version: '11.0.26') { + exclude group: 'org.slf4j', module: 'slf4j-api' + } + testImplementation(group: 'org.eclipse.jetty.toolchain', name: 'jetty-jakarta-servlet-api', version: '5.0.1') +} + +tasks.withType(JavaCompile).configureEach { + configureCompiler(it, 11, JavaVersion.VERSION_1_8) +} + +// testing happens in the jetty-* modules diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/src/main/java/datadog/trace/instrumentation/jetty1110/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/src/main/java/datadog/trace/instrumentation/jetty1110/MultipartHelper.java new file mode 100644 index 00000000000..3c233a763dd --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/src/main/java/datadog/trace/instrumentation/jetty1110/MultipartHelper.java @@ -0,0 +1,32 @@ +package datadog.trace.instrumentation.jetty1110; + +import jakarta.servlet.http.Part; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class MultipartHelper { + + private MultipartHelper() {} + + /** + * Extracts non-null, non-empty filenames from a collection of multipart {@link Part}s using + * {@link Part#getSubmittedFileName()} (Servlet 5.0+, Jetty 11.0.10+). + * + * @return list of filenames; never {@code null}, may be empty + */ + public static List extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/src/main/java/datadog/trace/instrumentation/jetty1110/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/src/main/java/datadog/trace/instrumentation/jetty1110/RequestExtractContentParametersInstrumentation.java new file mode 100644 index 00000000000..2abd2b7f26a --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0.10/src/main/java/datadog/trace/instrumentation/jetty1110/RequestExtractContentParametersInstrumentation.java @@ -0,0 +1,233 @@ +package datadog.trace.instrumentation.jetty1110; + +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 jakarta.servlet.http.Part; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; +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 11.0.10–11.0.x ([11.0.10, 12.0)): + // - _contentParameters + extractContentParameters(void) exist in 11.x (excludes Jetty 12 where + // org.eclipse.jetty.server.Request was removed) + // - _multiParts: MultiParts exists in 11.0.10+ (excludes 11.0.0–11.0.9 where it was + // MultiPartFormInputStream, covered by jetty-appsec-11.0) + // - jakarta.servlet.http.Part exists in 11.x classpath (excludes 9.4–10.x which use javax) + 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;") + .build(); + + private static final Reference JAKARTA_PART_REFERENCE = + new Reference.Builder("jakarta.servlet.http.Part").build(); + + @Override + public Reference[] additionalMuzzleReferences() { + return new Reference[] {REQUEST_REFERENCE, JAKARTA_PART_REFERENCE}; + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class ExtractContentParametersAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before(@Advice.FieldValue("_contentParameters") final MultiMap 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 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> callback = + cbp.getCallback(EVENTS.requestBodyProcessed()); + if (callback == null) { + return; + } + + Flow 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: + * + *
    + *
  • {@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code + * getParameterMap()} path); means filenames were already reported via {@code + * GetFilenamesFromMultiPartAdvice}. + *
  • {@code _multiParts != null}: set by the first {@code getParts()} call in Jetty 11.0.10+; + * means filenames were already reported. + *
+ */ + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue("_contentParameters") final MultiMap 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/build.gradle new file mode 100644 index 00000000000..0df8039febe --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/build.gradle @@ -0,0 +1,24 @@ +muzzle { + pass { + group = 'org.eclipse.jetty' + module = 'jetty-server' + versions = '[11.0,11.0.10)' + assertInverse = true + javaVersion = 11 + } +} + +apply from: "$rootDir/gradle/java.gradle" + +dependencies { + compileOnly(group: 'org.eclipse.jetty', name: 'jetty-server', version: '11.0.0') { + exclude group: 'org.slf4j', module: 'slf4j-api' + } + testImplementation(group: 'org.eclipse.jetty.toolchain', name: 'jetty-jakarta-servlet-api', version: '5.0.1') +} + +tasks.withType(JavaCompile).configureEach { + configureCompiler(it, 11, JavaVersion.VERSION_1_8) +} + +// testing happens in the jetty-* modules diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/gradle.lockfile b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/gradle.lockfile new file mode 100644 index 00000000000..ddb930c8200 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/gradle.lockfile @@ -0,0 +1,127 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-instrument-java:0.0.3=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.3=testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.14=testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.19=testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.18=testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.21=testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.24=testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=testRuntimeClasspath +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.1.1=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath +com.google.auto.service:auto-service:1.1.1=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.1=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,compileClasspath,spotbugs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.18.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.guava:failureaccess:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:20.0=testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:32.0.1-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,testAnnotationProcessor +com.google.re2j:re2j:1.7=testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:logging-interceptor:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.sqreen:libsqreen:17.3.0=testRuntimeClasspath +javax.servlet:javax.servlet-api:3.1.0=testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +junit:junit:4.13.2=testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.3=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.3=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath +net.java.dev.jna:jna:5.8.0=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.checkerframework:checker-qual:3.33.0=annotationProcessor,testAnnotationProcessor +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.eclipse.jetty.toolchain:jetty-jakarta-servlet-api:5.0.1=compileClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-http:11.0.0=compileClasspath +org.eclipse.jetty:jetty-io:11.0.0=compileClasspath +org.eclipse.jetty:jetty-server:11.0.0=compileClasspath +org.eclipse.jetty:jetty-util:11.0.0=compileClasspath +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest-core:1.3=testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-runner:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.0=spotbugs +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.9=spotbugs +org.ow2.asm:asm-commons:9.9.1=testRuntimeClasspath +org.ow2.asm:asm-tree:9.9=spotbugs +org.ow2.asm:asm-tree:9.9.1=testRuntimeClasspath +org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.9=spotbugs +org.ow2.asm:asm:9.9.1=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.30=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,muzzleTooling,runtimeClasspath,testRuntimeClasspath +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=spotbugsPlugins diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java new file mode 100644 index 00000000000..dc053ed19b8 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java @@ -0,0 +1,32 @@ +package datadog.trace.instrumentation.jetty11; + +import jakarta.servlet.http.Part; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class MultipartHelper { + + private MultipartHelper() {} + + /** + * Extracts non-null, non-empty filenames from a collection of multipart {@link Part}s using + * {@link Part#getSubmittedFileName()} (Servlet 5.0+, Jetty 11.x). + * + * @return list of filenames; never {@code null}, may be empty + */ + public static List extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java new file mode 100644 index 00000000000..b3401a5884c --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java @@ -0,0 +1,236 @@ +package datadog.trace.instrumentation.jetty11; + +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 jakarta.servlet.http.Part; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; +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 11.0.0–11.0.9 ([11.0, 11.0.10)): + // - _contentParameters + extractContentParameters(void) exist in 11.x (excludes Jetty 12) + // - _multiParts: MultiPartFormInputStream exists in 11.0.0–11.0.9 (excludes 11.0.10+ where it + // reverted to MultiParts, covered by jetty-appsec-11.0.10) + // - jakarta.servlet.http.Part exists in 11.x classpath (excludes 9.4–10.x which use javax) + 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/MultiPartFormInputStream;") + .build(); + + private static final Reference JAKARTA_PART_REFERENCE = + new Reference.Builder("jakarta.servlet.http.Part").build(); + + @Override + public Reference[] additionalMuzzleReferences() { + return new Reference[] {REQUEST_REFERENCE, JAKARTA_PART_REFERENCE}; + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class ExtractContentParametersAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before(@Advice.FieldValue("_contentParameters") final MultiMap 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 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> callback = + cbp.getCallback(EVENTS.requestBodyProcessed()); + if (callback == null) { + return; + } + + Flow 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: + * + *
    + *
  • {@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code + * getParameterMap()} path); means filenames were already reported via {@code + * GetFilenamesFromMultiPartAdvice}. + *
  • {@code _multiParts != null}: set by the first {@code getParts()} call in Jetty 11.0.x; + * means filenames were already reported. + *
+ */ + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue("_contentParameters") final MultiMap 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/groovy/datadog/trace/instrumentation/jetty11/MultipartHelperTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/groovy/datadog/trace/instrumentation/jetty11/MultipartHelperTest.groovy new file mode 100644 index 00000000000..2434cc72fae --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/groovy/datadog/trace/instrumentation/jetty11/MultipartHelperTest.groovy @@ -0,0 +1,71 @@ +package datadog.trace.instrumentation.jetty11 + +import jakarta.servlet.http.Part +import spock.lang.Specification + +class MultipartHelperTest extends Specification { + + def "returns empty list for null collection"() { + expect: + MultipartHelper.extractFilenames(null) == [] + } + + def "returns empty list for empty collection"() { + expect: + MultipartHelper.extractFilenames([]) == [] + } + + def "returns empty list when all parts have null filename"() { + given: + def parts = [part(null), part(null)] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "returns empty list when all parts have empty filename"() { + given: + def parts = [part(''), part('')] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "extracts filename from single part"() { + given: + def parts = [part('photo.jpg')] + + expect: + MultipartHelper.extractFilenames(parts) == ['photo.jpg'] + } + + def "extracts filenames from multiple parts"() { + given: + def parts = [part('a.jpg'), part('b.png'), part('c.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['a.jpg', 'b.png', 'c.pdf'] + } + + def "skips parts with null or empty filename and keeps valid ones"() { + given: + def parts = [part(null), part('valid.txt'), part(''), part('other.zip')] + + expect: + MultipartHelper.extractFilenames(parts) == ['valid.txt', 'other.zip'] + } + + def "preserves filenames with spaces and special characters"() { + given: + def parts = [part('my file.tar.gz'), part('résumé.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['my file.tar.gz', 'résumé.pdf'] + } + + private Part part(String submittedFileName) { + Part p = Stub(Part) + p.getSubmittedFileName() >> submittedFileName + return p + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/MultipartHelper.java new file mode 100644 index 00000000000..28426fd9667 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/MultipartHelper.java @@ -0,0 +1,38 @@ +package datadog.trace.instrumentation.jetty8; + +public class MultipartHelper { + + private MultipartHelper() {} + + /** + * Extracts the filename value from a {@code Content-Disposition} header string. + * + *

Jetty 8 implements Servlet 3.0, where {@code Part.getSubmittedFileName()} does not exist. + * This method parses the filename from the header manually. + * + *

Examples of handled inputs: + * + *

+   *   form-data; name="file"; filename="photo.jpg"  → "photo.jpg"
+   *   form-data; name="file"; filename=photo.jpg    → "photo.jpg"
+   * 
+ * + * @return the filename, or {@code null} if not present or empty + */ + public static String filenameFromContentDisposition(String cd) { + if (cd == null) { + return null; + } + for (String tok : cd.split(";")) { + tok = tok.trim(); + if (tok.startsWith("filename=")) { + String name = tok.substring(9).trim(); + if (name.startsWith("\"") && name.endsWith("\"")) { + name = name.substring(1, name.length() - 1); + } + return name.isEmpty() ? null : name; + } + } + return null; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/ParameterCollector.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/ParameterCollector.java index 73e7038238e..f021d96025c 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/ParameterCollector.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/ParameterCollector.java @@ -9,7 +9,9 @@ public interface ParameterCollector { boolean isEmpty(); - void put(String key, String value); + // Takes Object to accommodate both Jetty 8.x (MultiMap.add(Object,Object)) and + // Jetty 9.x (MultiMap.add(String,Object)) bytecode call sites. + void put(Object key, Object value); Map> getMap(); @@ -24,7 +26,7 @@ public boolean isEmpty() { } @Override - public void put(String key, String value) {} + public void put(Object key, Object value) {} @Override public Map> getMap() { @@ -39,16 +41,19 @@ public boolean isEmpty() { return map == null; } - public void put(String key, String value) { + public void put(Object key, Object value) { + if (!(key instanceof String) || !(value instanceof String)) { + return; + } if (map == null) { map = new HashMap<>(); } List strings = map.get(key); if (strings == null) { strings = new ArrayList<>(); - map.put(key, strings); + map.put((String) key, strings); } - strings.add(value); + strings.add((String) value); } @Override diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java index 104a3affa7c..444082a113c 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java @@ -7,6 +7,8 @@ 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.api.gateway.BlockResponseFunction; @@ -18,8 +20,12 @@ import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import java.util.function.BiFunction; import javax.servlet.ServletException; +import javax.servlet.http.Part; import net.bytebuddy.asm.Advice; import net.bytebuddy.asm.AsmVisitorWrapper; import net.bytebuddy.description.field.FieldDescription; @@ -27,6 +33,7 @@ import net.bytebuddy.description.method.MethodList; import net.bytebuddy.description.type.TypeDescription; import net.bytebuddy.implementation.Implementation; +import net.bytebuddy.implementation.bytecode.assign.Assigner; import net.bytebuddy.jar.asm.ClassReader; import net.bytebuddy.jar.asm.ClassVisitor; import net.bytebuddy.jar.asm.ClassWriter; @@ -58,6 +65,7 @@ public String[] helperClassNames() { packageName + ".ParameterCollector", packageName + ".ParameterCollector$ParameterCollectorImpl", packageName + ".ParameterCollector$ParameterCollectorNoop", + packageName + ".MultipartHelper", }; } @@ -74,6 +82,8 @@ public void methodAdvice(MethodTransformer transformer) { .and(takesArgument(0, String.class)) .or(named("getParts").and(takesArguments(0))), getClass().getName() + "$GetPartsAdvice"); + transformer.applyAdvice( + named("getParts").and(takesArguments(0)), getClass().getName() + "$GetFilenamesAdvice"); } @Override @@ -194,6 +204,60 @@ static void muzzle(Request req) throws ServletException, IOException { } } + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue(value = "_multiPartInputStream", typing = Assigner.Typing.DYNAMIC) + final Object multiPartInputStream) { + // _multiPartInputStream is null only on the first getParts() call; subsequent calls + // return the cached multipart result without re-parsing, but we must not re-fire the WAF. + return multiPartInputStream == null; + } + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.Enter boolean proceed, + @Advice.Return Collection parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + // Jetty 8 implements Servlet 3.0; getSubmittedFileName does not exist. + // Parse filename from Content-Disposition header instead. + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = + MultipartHelper.filenameFromContentDisposition(part.getHeader("content-disposition")); + if (name != null) { + filenames.add(name); + } + } + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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)"); + } + } + } + } + } + public static class GetPartsVisitorWrapper implements AsmVisitorWrapper { @Override public int mergeWriter(int flags) { @@ -249,10 +313,12 @@ public GetPartsMethodVisitor(int api, MethodVisitor superMv, int collectedParams @Override public void visitMethodInsn( int opcode, String owner, String name, String descriptor, boolean isInterface) { + // Match MultiMap.add() in both Jetty 8.x (Object,Object) and Jetty 9.x (String,Object). if (opcode == Opcodes.INVOKEVIRTUAL && owner.equals("org/eclipse/jetty/util/MultiMap") && name.equals("add") - && descriptor.equals("(Ljava/lang/String;Ljava/lang/Object;)V")) { + && (descriptor.equals("(Ljava/lang/String;Ljava/lang/Object;)V") + || descriptor.equals("(Ljava/lang/Object;Ljava/lang/Object;)V"))) { super.visitVarInsn(Opcodes.ALOAD, collectedParamsVar); // stack: ..., key, value, collParams super.visitInsn(Opcodes.DUP_X2); @@ -265,7 +331,7 @@ public void visitMethodInsn( Opcodes.INVOKEINTERFACE, Type.getInternalName(ParameterCollector.class), "put", - "(Ljava/lang/String;Ljava/lang/String;)V", + "(Ljava/lang/Object;Ljava/lang/Object;)V", true); // original stack } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/groovy/datadog/trace/instrumentation/jetty8/MultipartHelperTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/groovy/datadog/trace/instrumentation/jetty8/MultipartHelperTest.groovy new file mode 100644 index 00000000000..46d96465146 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/groovy/datadog/trace/instrumentation/jetty8/MultipartHelperTest.groovy @@ -0,0 +1,28 @@ +package datadog.trace.instrumentation.jetty8 + +import spock.lang.Specification +import spock.lang.Unroll + +class MultipartHelperTest extends Specification { + + @Unroll + def "filenameFromContentDisposition: #description"() { + expect: + MultipartHelper.filenameFromContentDisposition(cd) == expected + + where: + description | cd | expected + 'null input' | null | null + 'no filename token' | 'form-data; name="upload"' | null + 'quoted filename' | 'form-data; name="upload"; filename="photo.jpg"' | 'photo.jpg' + 'unquoted filename' | 'form-data; name="upload"; filename=photo.jpg' | 'photo.jpg' + 'filename with spaces' | 'form-data; filename="my file.jpg"' | 'my file.jpg' + 'empty quoted filename' | 'form-data; name="f"; filename=""' | null + 'empty unquoted filename' | 'form-data; name="f"; filename=' | null + 'whitespace-only unquoted filename' | 'form-data; name="f"; filename= ' | null + 'filename first token' | 'filename="evil.php"' | 'evil.php' + 'extra spaces around semicolons' | 'form-data ; name="f" ; filename="a.txt" ' | 'a.txt' + 'filename with dots and dashes' | 'form-data; filename="my-file.v2.tar.gz"' | 'my-file.v2.tar.gz' + 'stops at first filename= token' | 'form-data; filename="first.jpg"; filename="second.jpg"' | 'first.jpg' + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java new file mode 100644 index 00000000000..714a6bd5339 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java @@ -0,0 +1,32 @@ +package datadog.trace.instrumentation.jetty92; + +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+). + * + * @return list of filenames; never {@code null}, may be empty + */ + public static List extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java index 0796aa32538..f43bfd88143 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java @@ -19,7 +19,10 @@ 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 org.eclipse.jetty.server.Request; import org.eclipse.jetty.util.MultiMap; @@ -38,6 +41,11 @@ 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( @@ -48,6 +56,7 @@ public void methodAdvice(MethodTransformer transformer) { .and(takesArguments(1)) .and(takesArgument(0, named("org.eclipse.jetty.util.MultiMap"))), getClass().getName() + "$GetPartsAdvice"); + transformer.applyAdvice(named("getParts"), getClass().getName() + "$GetFilenamesAdvice"); } private static final Reference REQUEST_REFERENCE = @@ -135,4 +144,48 @@ static void after( } } } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before(@Advice.FieldValue("_contentParameters") final MultiMap map) { + final int callDepth = CallDepthThreadLocalMap.incrementCallDepth(Collection.class); + return callDepth == 0 && map == null; + } + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.Enter boolean proceed, + @Advice.Return Collection parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/groovy/datadog/trace/instrumentation/jetty92/MultipartHelperTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/groovy/datadog/trace/instrumentation/jetty92/MultipartHelperTest.groovy new file mode 100644 index 00000000000..51bdb50c564 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/groovy/datadog/trace/instrumentation/jetty92/MultipartHelperTest.groovy @@ -0,0 +1,71 @@ +package datadog.trace.instrumentation.jetty92 + +import javax.servlet.http.Part +import spock.lang.Specification + +class MultipartHelperTest extends Specification { + + def "returns empty list for null collection"() { + expect: + MultipartHelper.extractFilenames(null) == [] + } + + def "returns empty list for empty collection"() { + expect: + MultipartHelper.extractFilenames([]) == [] + } + + def "returns empty list when all parts have null filename"() { + given: + def parts = [part(null), part(null)] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "returns empty list when all parts have empty filename"() { + given: + def parts = [part(''), part('')] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "extracts filename from single part"() { + given: + def parts = [part('photo.jpg')] + + expect: + MultipartHelper.extractFilenames(parts) == ['photo.jpg'] + } + + def "extracts filenames from multiple parts"() { + given: + def parts = [part('a.jpg'), part('b.png'), part('c.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['a.jpg', 'b.png', 'c.pdf'] + } + + def "skips parts with null or empty filename and keeps valid ones"() { + given: + def parts = [part(null), part('valid.txt'), part(''), part('other.zip')] + + expect: + MultipartHelper.extractFilenames(parts) == ['valid.txt', 'other.zip'] + } + + def "preserves filenames with spaces and special characters"() { + given: + def parts = [part('my file.tar.gz'), part('résumé.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['my file.tar.gz', 'résumé.pdf'] + } + + private Part part(String submittedFileName) { + Part p = Stub(Part) + p.getSubmittedFileName() >> submittedFileName + return p + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/build.gradle index 69bad38c12b..f151c5573aa 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/build.gradle @@ -2,7 +2,7 @@ muzzle { pass { group = 'org.eclipse.jetty' module = 'jetty-server' - versions = '[9.3,12)' + versions = '[9.3,9.4.10)' assertInverse = true } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java new file mode 100644 index 00000000000..e54f72b8ff7 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java @@ -0,0 +1,32 @@ +package datadog.trace.instrumentation.jetty93; + +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 9.3.x). + * + * @return list of filenames; never {@code null}, may be empty + */ + public static List extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java index 3e1e2bf6d5c..f9912787b33 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java @@ -18,8 +18,12 @@ 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; @@ -37,17 +41,36 @@ 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 9.3.x–9.4.9.x ([9.3, 9.4.10)): + // - _contentParameters + extractContentParameters(void) exist from 9.3+ (excludes 9.2) + // - _multiPartInputStream exists in 9.3.x and early 9.4.x (< 9.4.10); replaced by _multiParts + // in 9.4.10 (covered by jetty-appsec-9.4) 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, + "_multiPartInputStream", + "Lorg/eclipse/jetty/util/MultiPartInputStreamParser;") .build(); @Override @@ -99,4 +122,114 @@ static void after( } } } + + /** + * Fires the {@code requestFilesFilenames} event when the application calls public {@code + * getParts()}. Guards prevent double-firing: + * + *
    + *
  • {@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code + * getParameterMap()} path); means filenames were already reported via {@code + * GetFilenamesFromMultiPartAdvice}. + *
  • {@code _multiPartInputStream != null}: set by the first {@code getParts()} call in Jetty + * 9.3.x; means filenames were already reported. + *
+ */ + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue("_contentParameters") final MultiMap contentParameters, + @Advice.FieldValue(value = "_multiPartInputStream", typing = Assigner.Typing.DYNAMIC) + final Object multiPartInputStream) { + final int callDepth = CallDepthThreadLocalMap.incrementCallDepth(Collection.class); + return callDepth == 0 && contentParameters == null && multiPartInputStream == null; + } + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.Enter boolean proceed, + @Advice.Return Collection parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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()}. In Jetty + * 9.3+, {@code extractContentParameters()} assigns {@code _contentParameters} before calling this + * method, so {@code map == null} cannot be used as a "first parse" guard here; the call-depth + * guard prevents double-firing when {@code getParts()} internally delegates to this method. + */ + @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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/groovy/datadog/trace/instrumentation/jetty93/MultipartHelperTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/groovy/datadog/trace/instrumentation/jetty93/MultipartHelperTest.groovy new file mode 100644 index 00000000000..31489cb96f9 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/groovy/datadog/trace/instrumentation/jetty93/MultipartHelperTest.groovy @@ -0,0 +1,71 @@ +package datadog.trace.instrumentation.jetty93 + +import javax.servlet.http.Part +import spock.lang.Specification + +class MultipartHelperTest extends Specification { + + def "returns empty list for null collection"() { + expect: + MultipartHelper.extractFilenames(null) == [] + } + + def "returns empty list for empty collection"() { + expect: + MultipartHelper.extractFilenames([]) == [] + } + + def "returns empty list when all parts have null filename"() { + given: + def parts = [part(null), part(null)] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "returns empty list when all parts have empty filename"() { + given: + def parts = [part(''), part('')] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "extracts filename from single part"() { + given: + def parts = [part('photo.jpg')] + + expect: + MultipartHelper.extractFilenames(parts) == ['photo.jpg'] + } + + def "extracts filenames from multiple parts"() { + given: + def parts = [part('a.jpg'), part('b.png'), part('c.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['a.jpg', 'b.png', 'c.pdf'] + } + + def "skips parts with null or empty filename and keeps valid ones"() { + given: + def parts = [part(null), part('valid.txt'), part(''), part('other.zip')] + + expect: + MultipartHelper.extractFilenames(parts) == ['valid.txt', 'other.zip'] + } + + def "preserves filenames with spaces and special characters"() { + given: + def parts = [part('my file.tar.gz'), part('résumé.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['my file.tar.gz', 'résumé.pdf'] + } + + private Part part(String submittedFileName) { + Part p = Stub(Part) + p.getSubmittedFileName() >> submittedFileName + return p + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/build.gradle new file mode 100644 index 00000000000..a38fc1316b4 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/build.gradle @@ -0,0 +1,16 @@ +muzzle { + pass { + group = 'org.eclipse.jetty' + module = 'jetty-server' + versions = '[9.4.10,10.0)' + assertInverse = true + } +} + +apply from: "$rootDir/gradle/java.gradle" + +dependencies { + compileOnly group: 'org.eclipse.jetty', name: 'jetty-server', version: '9.4.21.v20190926' +} + +// testing happens in the jetty-* modules diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/gradle.lockfile b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/gradle.lockfile new file mode 100644 index 00000000000..961940d455a --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/gradle.lockfile @@ -0,0 +1,126 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-instrument-java:0.0.3=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.3=testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.14=testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.19=testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.18=testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.21=testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.24=testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=testRuntimeClasspath +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.1.1=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath +com.google.auto.service:auto-service:1.1.1=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.1=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,compileClasspath,spotbugs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.18.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.guava:failureaccess:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:20.0=testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:32.0.1-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,testAnnotationProcessor +com.google.re2j:re2j:1.7=testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:logging-interceptor:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.sqreen:libsqreen:17.3.0=testRuntimeClasspath +javax.servlet:javax.servlet-api:3.1.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +junit:junit:4.13.2=testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.3=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.3=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath +net.java.dev.jna:jna:5.8.0=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.checkerframework:checker-qual:3.33.0=annotationProcessor,testAnnotationProcessor +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.eclipse.jetty:jetty-http:9.4.21.v20190926=compileClasspath +org.eclipse.jetty:jetty-io:9.4.21.v20190926=compileClasspath +org.eclipse.jetty:jetty-server:9.4.21.v20190926=compileClasspath +org.eclipse.jetty:jetty-util:9.4.21.v20190926=compileClasspath +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest-core:1.3=testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-runner:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.0=spotbugs +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.9=spotbugs +org.ow2.asm:asm-commons:9.9.1=testRuntimeClasspath +org.ow2.asm:asm-tree:9.9=spotbugs +org.ow2.asm:asm-tree:9.9.1=testRuntimeClasspath +org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.9=spotbugs +org.ow2.asm:asm:9.9.1=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.30=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,muzzleTooling,runtimeClasspath,testRuntimeClasspath +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=spotbugsPlugins diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java new file mode 100644 index 00000000000..49a71f632dc --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java @@ -0,0 +1,32 @@ +package datadog.trace.instrumentation.jetty94; + +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 9.4.x–10.x). + * + * @return list of filenames; never {@code null}, may be empty + */ + public static List extractFilenames(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List filenames = new ArrayList<>(); + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + return filenames; + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java new file mode 100644 index 00000000000..8d67aaba2a4 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java @@ -0,0 +1,235 @@ +package datadog.trace.instrumentation.jetty94; + +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 9.4.10–9.4.x ([9.4.10, 10.0)): + // - _contentParameters + extractContentParameters(void) exist from 9.3+ (excludes 9.2) + // - _multiParts: MultiParts exists in 9.4.10+ (excludes early 9.4.x covered by + // jetty-appsec-9.3, and excludes 10.0.0–10.0.9 where it is MultiPartFormInputStream) + // - _queryEncoding: String exists only in 9.4.x; changed to Charset in all 10.x (excludes + // 10.0.10+ where _multiParts reverted to MultiParts) + // - javax.servlet.http.Part exists in 9.4.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/lang/String;") + .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 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 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> callback = + cbp.getCallback(EVENTS.requestBodyProcessed()); + if (callback == null) { + return; + } + + Flow 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: + * + *
    + *
  • {@code _contentParameters != null}: set by {@code extractContentParameters()} (the {@code + * getParameterMap()} path); means filenames were already reported via {@code + * GetFilenamesFromMultiPartAdvice}. + *
  • {@code _multiParts != null}: set by the first {@code getParts()} call in Jetty 9.4.10+; + * means filenames were already reported. + *
+ */ + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class GetFilenamesAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static boolean before( + @Advice.FieldValue("_contentParameters") final MultiMap 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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 parts, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + CallDepthThreadLocalMap.decrementCallDepth(Collection.class); + if (!proceed || t != null || parts == null || parts.isEmpty()) { + return; + } + List filenames = MultipartHelper.extractFilenames(parts); + if (filenames.isEmpty()) { + return; + } + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + Flow 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(); + } + } + } + } + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/groovy/datadog/trace/instrumentation/jetty94/MultipartHelperTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/groovy/datadog/trace/instrumentation/jetty94/MultipartHelperTest.groovy new file mode 100644 index 00000000000..af027e5dd65 --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/groovy/datadog/trace/instrumentation/jetty94/MultipartHelperTest.groovy @@ -0,0 +1,71 @@ +package datadog.trace.instrumentation.jetty94 + +import javax.servlet.http.Part +import spock.lang.Specification + +class MultipartHelperTest extends Specification { + + def "returns empty list for null collection"() { + expect: + MultipartHelper.extractFilenames(null) == [] + } + + def "returns empty list for empty collection"() { + expect: + MultipartHelper.extractFilenames([]) == [] + } + + def "returns empty list when all parts have null filename"() { + given: + def parts = [part(null), part(null)] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "returns empty list when all parts have empty filename"() { + given: + def parts = [part(''), part('')] + + expect: + MultipartHelper.extractFilenames(parts) == [] + } + + def "extracts filename from single part"() { + given: + def parts = [part('photo.jpg')] + + expect: + MultipartHelper.extractFilenames(parts) == ['photo.jpg'] + } + + def "extracts filenames from multiple parts"() { + given: + def parts = [part('a.jpg'), part('b.png'), part('c.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['a.jpg', 'b.png', 'c.pdf'] + } + + def "skips parts with null or empty filename and keeps valid ones"() { + given: + def parts = [part(null), part('valid.txt'), part(''), part('other.zip')] + + expect: + MultipartHelper.extractFilenames(parts) == ['valid.txt', 'other.zip'] + } + + def "preserves filenames with spaces and special characters"() { + given: + def parts = [part('my file.tar.gz'), part('résumé.pdf')] + + expect: + MultipartHelper.extractFilenames(parts) == ['my file.tar.gz', 'résumé.pdf'] + } + + private Part part(String submittedFileName) { + Part p = Stub(Part) + p.getSubmittedFileName() >> submittedFileName + return p + } +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/build.gradle index 334d032273c..5ae70873e2c 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/build.gradle @@ -63,7 +63,8 @@ dependencies { testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-7.0') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-8.1.3') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.2') - testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3') + testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0') + testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0.10') // Include all websocket instrumentation modules for testing. Only the version-compatible module will apply at runtime. testRuntimeOnly project(':dd-java-agent:instrumentation:websocket:javax-websocket-1.0') testRuntimeOnly project(':dd-java-agent:instrumentation:websocket:jakarta-websocket-2.0') @@ -72,13 +73,15 @@ dependencies { latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-server', version: '10.+' latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '10.+' latestDepTestImplementation group: 'org.eclipse.jetty.websocket', name: 'websocket-javax-server', version: '10.+' - latestDepTestImplementation project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3') + latestDepTestImplementation project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0') + latestDepTestImplementation project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0.10') latestDepTestImplementation testFixtures(project(':dd-java-agent:instrumentation:servlet:javax-servlet:javax-servlet-3.0')) latestDepForkedTestImplementation group: 'org.eclipse.jetty', name: 'jetty-server', version: '10.+' latestDepForkedTestImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '10.+' latestDepForkedTestImplementation group: 'org.eclipse.jetty.websocket', name: 'websocket-javax-server', version: '10.+' - latestDepForkedTestImplementation project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3') + latestDepForkedTestImplementation project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0') + latestDepForkedTestImplementation project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0.10') latestDepForkedTestImplementation testFixtures(project(':dd-java-agent:instrumentation:servlet:javax-servlet:javax-servlet-3.0')) } diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy index 7dec61c223f..2726574ec83 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy @@ -85,6 +85,21 @@ abstract class Jetty10Test extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/build.gradle index 119dd38ea12..b4f8ee26098 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/build.gradle @@ -47,7 +47,8 @@ dependencies { testImplementation ("org.eclipse.jetty.websocket:websocket-jakarta-server:11.0.0") { exclude group: 'org.slf4j', module: 'slf4j-api' } - testImplementation(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3')) + testImplementation(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-11.0')) + testImplementation(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-11.0.10')) testImplementation testFixtures(project(':dd-java-agent:instrumentation:servlet:jakarta-servlet-5.0')) testImplementation project(':dd-java-agent:appsec:appsec-test-fixtures') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-server:jetty-server-9.0') diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy index f4da48aaaf3..80afb31077a 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy @@ -67,6 +67,21 @@ abstract class Jetty11Test extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + @Override boolean testBlocking() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/JettyAsyncHandlerTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/JettyAsyncHandlerTest.groovy index 38f5b1449ab..bd1e1bb9ecc 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/JettyAsyncHandlerTest.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/JettyAsyncHandlerTest.groovy @@ -25,6 +25,11 @@ class JettyAsyncHandlerTest extends Jetty11Test implements TestingGenericHttpNam false } + @Override + boolean testBodyFilenames() { + false + } + static class ContinuationTestHandler implements Handler { @Delegate private final Handler delegate diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-12.0/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-12.0/build.gradle index d2346ef072a..e0ad7398689 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-12.0/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-12.0/build.gradle @@ -67,6 +67,9 @@ dependencies { } testImplementation(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3')) + testRuntimeOnly(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.4')) + testRuntimeOnly(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-11.0')) + testRuntimeOnly(project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-11.0.10')) testImplementation testFixtures(project(':dd-java-agent:instrumentation:servlet:jakarta-servlet-5.0')) testImplementation testFixtures(project(':dd-java-agent:instrumentation:servlet:javax-servlet:javax-servlet-3.0')) testRuntimeOnly project(':dd-java-agent:instrumentation:websocket:javax-websocket-1.0') diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/build.gradle index 613f00a4f3e..0b34ae39684 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/build.gradle @@ -20,6 +20,11 @@ configurations.testRuntimeOnly { exclude group: 'javax.servlet', module: 'javax.servlet-api' } +tasks.named('latestDepForkedTest', Test) { + // Signal that we are running against Jetty 8.x so Jetty8*LatestDepForkedTest activates. + systemProperty 'test.dd.filenames', 'true' +} + dependencies { compileOnly group: 'org.eclipse.jetty', name: 'jetty-server', version: '7.6.0.v20120127' implementation project(':dd-java-agent:instrumentation:jetty:jetty-common') @@ -34,8 +39,14 @@ dependencies { testImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '7.6.0.v20120127' testImplementation group: 'org.eclipse.jetty', name: 'jetty-continuation', version: '7.6.0.v20120127' testImplementation testFixtures(project(':dd-java-agent:instrumentation:servlet:javax-servlet:javax-servlet-3.0')) + // Needed to compile Jetty8LatestDepForkedTest (provides MultipartConfigElement). + // Uses the Orbit repackaging so it is not caught by the javax.servlet:javax.servlet-api exclusion. + // Compile-only: the Orbit jar is provided at runtime by Jetty 8.x in the latestDepForkedTest. + testCompileOnly group: 'org.eclipse.jetty.orbit', name: 'javax.servlet', version: '3.0.0.v201112011016' testRuntimeOnly project(':dd-java-agent:instrumentation:servlet:javax-servlet:javax-servlet-2.2') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-7.0') + // Activated only on Jetty 8.x (muzzle rejects it for 7.6) + testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-8.1.3') latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-server', version: '8.+' latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '8.+' diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/gradle.lockfile b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/gradle.lockfile index b6cc9477c24..c38e91494ca 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/gradle.lockfile +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/gradle.lockfile @@ -1,26 +1,26 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -cafe.cryptography:curve25519-elisabeth:0.1.0=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -cafe.cryptography:ed25519-elisabeth:0.1.0=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +cafe.cryptography:curve25519-elisabeth:0.1.0=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.2.13=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.2.13=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.datadoghq.okhttp3:okhttp:3.12.15=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.datadoghq.okio:okio:1.17.6=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.datadoghq:dd-instrument-java:0.0.3=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.datadoghq:java-dogstatsd-client:4.4.3=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.datadoghq:sketches-java:0.8.3=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.3=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath com.github.javaparser:javaparser-core:3.25.6=codenarc -com.github.jnr:jffi:1.3.14=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-a64asm:1.0.0=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-constants:0.10.4=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-enxio:0.32.19=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-ffi:2.2.18=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-posix:3.1.21=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-unixsocket:0.38.24=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.github.jnr:jnr-x86asm:1.0.2=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jffi:1.3.14=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.19=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.18=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.21=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.24=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs:4.9.8=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs @@ -36,11 +36,11 @@ com.google.guava:guava:20.0=latestDepForkedTestCompileClasspath,latestDepForkedT com.google.guava:guava:32.0.1-jre=annotationProcessor,latestDepForkedTestAnnotationProcessor,testAnnotationProcessor com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,latestDepForkedTestAnnotationProcessor,testAnnotationProcessor com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,latestDepForkedTestAnnotationProcessor,testAnnotationProcessor -com.google.re2j:re2j:1.7=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -com.squareup.moshi:moshi:1.11.0=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.re2j:re2j:1.7=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okhttp3:logging-interceptor:3.12.12=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okhttp3:okhttp:3.12.12=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio:1.17.5=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-fileupload:commons-fileupload:1.5=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.11.0=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -53,8 +53,8 @@ jaxen:jaxen:2.0.0=spotbugs junit:junit:4.13.2=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.18.3=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.18.3=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna-platform:5.8.0=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.8.0=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.8.0=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.8.0=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=spotbugs org.apache.ant:ant-antlr:1.10.14=codenarc org.apache.ant:ant-junit:1.10.14=codenarc @@ -76,7 +76,7 @@ org.codehaus.groovy:groovy:3.0.23=codenarc org.codehaus.groovy:groovy:3.0.25=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codenarc:CodeNarc:3.7.0=codenarc org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.jetty.orbit:javax.servlet:3.0.0.v201112011016=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,latestDepTestImplementation +org.eclipse.jetty.orbit:javax.servlet:3.0.0.v201112011016=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,latestDepTestImplementation,testCompileClasspath org.eclipse.jetty:jetty-continuation:7.6.0.v20120127=compileClasspath,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-continuation:8.2.0.v20160908=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,latestDepTestImplementation org.eclipse.jetty:jetty-http:7.6.0.v20120127=compileClasspath,testCompileClasspath,testRuntimeClasspath @@ -94,8 +94,8 @@ org.eclipse.jetty:jetty-util:8.2.0.v20160908=latestDepForkedTestCompileClasspath org.gmetrics:GMetrics:2.1.0=codenarc org.hamcrest:hamcrest-core:1.3=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core-jdk11:4.0.6=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +org.jctools:jctools-core:4.0.6=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:5.14.1=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -111,19 +111,16 @@ org.junit:junit-bom:5.14.1=latestDepForkedTestCompileClasspath,latestDepForkedTe org.mockito:mockito-core:4.4.0=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.ow2.asm:asm-analysis:9.7.1=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.7.1=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath org.ow2.asm:asm-analysis:9.9=spotbugs -org.ow2.asm:asm-commons:9.7.1=buildTimeInstrumentationPlugin,muzzleTooling,runtimeClasspath org.ow2.asm:asm-commons:9.9=spotbugs org.ow2.asm:asm-commons:9.9.1=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath -org.ow2.asm:asm-tree:9.7.1=buildTimeInstrumentationPlugin,muzzleTooling,runtimeClasspath org.ow2.asm:asm-tree:9.9=spotbugs org.ow2.asm:asm-tree:9.9.1=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath -org.ow2.asm:asm-util:9.7.1=buildTimeInstrumentationPlugin,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +org.ow2.asm:asm-util:9.7.1=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath org.ow2.asm:asm-util:9.9=spotbugs -org.ow2.asm:asm:9.7.1=buildTimeInstrumentationPlugin,muzzleTooling,runtimeClasspath org.ow2.asm:asm:9.9=spotbugs -org.ow2.asm:asm:9.9.1=latestDepForkedTestRuntimeClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.9.1=buildTimeInstrumentationPlugin,compileClasspath,latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jcl-over-slf4j:1.7.30=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:1.7.30=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:log4j-over-slf4j:1.7.30=latestDepForkedTestCompileClasspath,latestDepForkedTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.groovy new file mode 100644 index 00000000000..f52ca00a39b --- /dev/null +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.groovy @@ -0,0 +1,96 @@ +import datadog.trace.agent.test.base.HttpServerTest +import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions +import org.eclipse.jetty.server.Request +import org.eclipse.jetty.server.handler.AbstractHandler +import spock.lang.Requires + +import javax.servlet.MultipartConfigElement +import javax.servlet.ServletException +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * Integration tests for multipart filename extraction on Jetty 8.x. + * + *

Jetty 8.x introduced Servlet 3.0 and {@code getParts()}, which is the only entry point for + * multipart processing in this version range (there is no {@code extractContentParameters()} + * instrumentation like in 9.3+). The handler must therefore call {@code getParts()} explicitly + * before {@code getParameterMap()} so that multipart form fields are visible to the servlet. + * + *

Only activated for the {@code latestDepForkedTest} Gradle task (Jetty 8.x). The + * {@code test.dd.filenames} system property gates execution, preventing these tests from + * running against Jetty 7.6 where {@code getParts()} does not exist. + */ +abstract class Jetty8LatestDepForkedTest extends Jetty76Test { + + @Override + AbstractHandler handler() { + new Jetty8TestHandler() + } + + @Override + boolean testBodyMultipart() { + true + } + + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + + static class Jetty8TestHandler extends AbstractHandler { + private static final MultipartConfigElement MULTIPART_CONFIG = + new MultipartConfigElement(System.getProperty('java.io.tmpdir')) + + @Override + void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + if (baseRequest.dispatcherType.name() != 'ERROR') { + // Enable Servlet 3.0 multipart processing for all requests. + request.setAttribute('org.eclipse.jetty.multipartConfig', MULTIPART_CONFIG) + request.setAttribute('org.eclipse.multipartConfig', MULTIPART_CONFIG) + + // Jetty 8.x does not populate getParameterMap() from multipart form fields without a + // prior getParts() call (unlike 9.3+ where extractContentParameters() does this). + // Pre-call getParts() for BODY_MULTIPART so the servlet can read form fields via + // getParameterMap(). Skip for BODY_MULTIPART_REPEATED and BODY_MULTIPART_COMBINED, + // which call getParts() themselves and rely on the first call triggering filenames. + def endpoint = HttpServerTest.ServerEndpoint.forPath(request.requestURI) + if (endpoint == HttpServerTest.ServerEndpoint.BODY_MULTIPART) { + try { + request.getParts() + } catch (IOException | ServletException ignored) {} + } + + Jetty76Test.TestHandler.handleRequest(baseRequest, response) + baseRequest.handled = true + } else { + Jetty76Test.errorHandler.handle(target, baseRequest, response, response) + } + } + } +} + +@Requires({ + System.getProperty('test.dd.filenames') +}) +class Jetty8V0LatestDepForkedTest extends Jetty8LatestDepForkedTest +implements TestingGenericHttpNamingConventions.ServerV0 { +} + +@Requires({ + System.getProperty('test.dd.filenames') +}) +class Jetty8V1LatestDepForkedTest extends Jetty8LatestDepForkedTest +implements TestingGenericHttpNamingConventions.ServerV1 { +} diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy index 38eb20340c6..8a18ccbc652 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy @@ -85,6 +85,21 @@ abstract class Jetty9Test extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy index 32a1b300c28..9bdc9e1e469 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy @@ -84,6 +84,21 @@ abstract class Jetty9Test extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/build.gradle index 5d08c44f4c9..736630c640e 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/build.gradle @@ -41,6 +41,7 @@ dependencies { testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-8.1.3') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.2') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3') + testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.4') latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-server', version: '9.4.20.v20190813' latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '9.4.20.v20190813' diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy index 32a1b300c28..9bdc9e1e469 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy @@ -84,6 +84,21 @@ abstract class Jetty9Test extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/build.gradle b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/build.gradle index d9ef585146e..144358e05f5 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/build.gradle +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/build.gradle @@ -42,7 +42,7 @@ dependencies { testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-7.0') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-8.1.3') testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.2') - testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3') + testRuntimeOnly project(':dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.4') latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-server', version: '9.+' latestDepTestImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '9.+' diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy index 38eb20340c6..8a18ccbc652 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy @@ -85,6 +85,21 @@ abstract class Jetty9Test extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + + @Override + boolean testBodyFilenamesCalledOnce() { + true + } + + @Override + boolean testBodyFilenamesCalledOnceCombined() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/servlet/jakarta-servlet-5.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/TestServlet5.groovy b/dd-java-agent/instrumentation/servlet/jakarta-servlet-5.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/TestServlet5.groovy index 51e7c974f6d..93060644456 100644 --- a/dd-java-agent/instrumentation/servlet/jakarta-servlet-5.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/TestServlet5.groovy +++ b/dd-java-agent/instrumentation/servlet/jakarta-servlet-5.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/TestServlet5.groovy @@ -11,6 +11,8 @@ import java.lang.reflect.Field import java.lang.reflect.Modifier 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 @@ -68,6 +70,22 @@ class TestServlet5 extends HttpServlet { resp.status = endpoint.status resp.writer.print(req.getHeader("x-forwarded-for")) break + case BODY_MULTIPART_REPEATED: + resp.status = endpoint.status + // Call getParts() 3 times to verify the filenames callback fires only once + req.getParts() + req.getParts() + req.getParts() + resp.writer.print(endpoint.body) + break + case BODY_MULTIPART_COMBINED: + resp.status = endpoint.status + // Call getParameterMap() first (exercises GetFilenamesFromMultiPartAdvice via extractContentParameters), + // then getParts() explicitly (GetFilenamesAdvice must not double-fire since map is already set) + req.parameterMap + req.getParts() + resp.writer.print(endpoint.body) + break case BODY_MULTIPART: case BODY_URLENCODED: resp.status = endpoint.status diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/TestServlet3.groovy b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/TestServlet3.groovy index 4b0b9df85d4..98a5983a36d 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/TestServlet3.groovy +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/TestServlet3.groovy @@ -15,6 +15,8 @@ import java.lang.reflect.Modifier import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_URLENCODED 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.CREATED import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.CREATED_IS import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.CUSTOM_EXCEPTION @@ -95,6 +97,22 @@ class TestServlet3 { resp.status = endpoint.status resp.writer.print(endpoint.bodyForQuery(req.queryString)) break + case BODY_MULTIPART_REPEATED: + resp.status = endpoint.status + // Call getParts() 3 times to verify the filenames callback fires only once + req.getParts() + req.getParts() + req.getParts() + resp.writer.print(endpoint.body) + break + case BODY_MULTIPART_COMBINED: + resp.status = endpoint.status + // Call getParameterMap() first (exercises GetFilenamesFromMultiPartAdvice via extractContentParameters), + // then getParts() explicitly (GetFilenamesAdvice must not double-fire since map is already set) + req.parameterMap + req.getParts() + resp.writer.print(endpoint.body) + break case BODY_URLENCODED: case BODY_MULTIPART: resp.status = endpoint.status diff --git a/settings.gradle.kts b/settings.gradle.kts index 0bac052092a..3ba09e4193b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -414,6 +414,11 @@ include( ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-8.1.3", ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.2", ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.3", + ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-9.4", + ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0", + ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-10.0.10", + ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-11.0", + ":dd-java-agent:instrumentation:jetty:jetty-appsec:jetty-appsec-11.0.10", ":dd-java-agent:instrumentation:jetty:jetty-client:jetty-client-10.0", ":dd-java-agent:instrumentation:jetty:jetty-client:jetty-client-12.0", ":dd-java-agent:instrumentation:jetty:jetty-client:jetty-client-9.1",