Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
629f074
Instrument Jetty for server.request.body.filenames
jandro996 Mar 23, 2026
b276e16
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 6, 2026
b1ec26b
Fix GetFilenamesAdvice double-firing and extend coverage to getParts(…
jandro996 Apr 6, 2026
d8a92f8
spotless
jandro996 Apr 7, 2026
4d8ca56
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 8, 2026
30bb769
Extend testBodyFilenamesCalledOnce coverage to Jetty 9.x and 10.x
jandro996 Apr 7, 2026
abddcfa
Add BODY_MULTIPART_COMBINED test to cover GetFilenamesFromMultiPartAd…
jandro996 Apr 8, 2026
eeab933
spotless
jandro996 Apr 8, 2026
0040e75
Fix missing static imports for BODY_MULTIPART_REPEATED and BODY_MULTI…
jandro996 Apr 8, 2026
c5268dd
Fix GetFilenamesAdvice double-fire for Jetty 9.4+ where _multiParts r…
jandro996 Apr 8, 2026
db08e43
Fix GetFilenamesAdvice double-fire in jetty-appsec-8.1.3
jandro996 Apr 8, 2026
30e4b8c
Fix GetFilenamesAdvice double-fire for all Jetty 9.3–11 versions
jandro996 Apr 8, 2026
3fd02e3
Merge branch 'master' into alejandro.gonzalez/APPSEC-61873-3
jandro996 Apr 9, 2026
7f391b4
Simplify GetFilenamesAdvice in jetty-appsec-8.1.3: remove dead Servle…
jandro996 Apr 9, 2026
246f4e3
Remove unnecessary casts in Jetty AppSec GetFilenamesAdvice
jandro996 Apr 9, 2026
20f8bb4
Extract Content-Disposition parsing to MultipartHelper + unit tests
jandro996 Apr 9, 2026
398ea51
Extract filename extraction to MultipartHelper in jetty-appsec-9.2 + …
jandro996 Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import java.util.function.Supplier

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

boolean testBodyFilenamesCalledOnce() {
false
}

boolean testBodyFilenamesCalledOnceCombined() {
false
}

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

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

when:
TEST_WRITER.waitForTraces(1)

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

cleanup:
response.close()
}

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

when:
TEST_WRITER.waitForTraces(1)

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

cleanup:
response.close()
}

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package datadog.trace.instrumentation.jetty8;

public class MultipartHelper {

private MultipartHelper() {}

/**
* Extracts the filename value from a {@code Content-Disposition} header string.
*
* <p>Jetty 8 implements Servlet 3.0, where {@code Part.getSubmittedFileName()} does not exist.
* This method parses the filename from the header manually.
*
* <p>Examples of handled inputs:
*
* <pre>
* form-data; name="file"; filename="photo.jpg" → "photo.jpg"
* form-data; name="file"; filename=photo.jpg → "photo.jpg"
* </pre>
*
* @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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,15 +20,20 @@
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;
import net.bytebuddy.description.field.FieldList;
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;
Expand Down Expand Up @@ -58,6 +65,7 @@ public String[] helperClassNames() {
packageName + ".ParameterCollector",
packageName + ".ParameterCollector$ParameterCollectorImpl",
packageName + ".ParameterCollector$ParameterCollectorNoop",
packageName + ".MultipartHelper",
};
}

Expand All @@ -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
Expand Down Expand Up @@ -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<Part> 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<String> 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<RequestContext, List<String>, Flow<Void>> callback =
cbp.getCallback(EVENTS.requestFilesFilenames());
if (callback == null) {
return;
}
Flow<Void> flow = callback.apply(reqCtx, filenames);
Flow.Action action = flow.getAction();
if (action instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
if (brf != null) {
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (t == null) {
t = new BlockingException("Blocked request (multipart file upload)");
}
}
}
}
}

public static class GetPartsVisitorWrapper implements AsmVisitorWrapper {
@Override
public int mergeWriter(int flags) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
}
}
Original file line number Diff line number Diff line change
@@ -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<String> extractFilenames(Collection<Part> parts) {
if (parts == null || parts.isEmpty()) {
return Collections.emptyList();
}
List<String> filenames = new ArrayList<>();
for (Part part : parts) {
String name = part.getSubmittedFileName();
if (name != null && !name.isEmpty()) {
filenames.add(name);
}
}
return filenames;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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 =
Expand Down Expand Up @@ -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<String> 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<Part> parts,
@ActiveRequestContext RequestContext reqCtx,
@Advice.Thrown(readOnly = false) Throwable t) {
CallDepthThreadLocalMap.decrementCallDepth(Collection.class);
if (!proceed || t != null || parts == null || parts.isEmpty()) {
return;
}
List<String> filenames = MultipartHelper.extractFilenames(parts);
if (filenames.isEmpty()) {
return;
}
CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC);
BiFunction<RequestContext, List<String>, Flow<Void>> callback =
cbp.getCallback(EVENTS.requestFilesFilenames());
if (callback == null) {
return;
}
Flow<Void> flow = callback.apply(reqCtx, filenames);
Flow.Action action = flow.getAction();
if (action instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
if (brf != null) {
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (t == null) {
t = new BlockingException("Blocked request (multipart file upload)");
reqCtx.getTraceSegment().effectivelyBlocked();
}
}
}
}
}
}
Loading
Loading