From 5918babce713a0bc7b0b2c0ad8cc11786c1b9a3c Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Mon, 23 Mar 2026 10:34:14 +0100 Subject: [PATCH 1/6] Add server.request.body.filenames IG event and GatewayBridge wiring - Add REQUEST_FILES_FILENAMES_ID=30 event to Events.java with BiFunction, Flow> callback type - Register case in InstrumentationGateway switch to wrap with try-catch - Wire GatewayBridge: conditional registration, handler, cache field, reset, and IGAppSecEventDependencies entry - Add unit tests in InstrumentationGatewayTest and GatewayBridgeSpecification tag: ai generated Co-Authored-By: Claude Sonnet 4.6 --- .../datadog/appsec/gateway/GatewayBridge.java | 33 ++++++++++++++++ .../gateway/GatewayBridgeSpecification.groovy | 38 ++++++++++++++++++- .../datadog/trace/api/gateway/Events.java | 14 +++++++ .../api/gateway/InstrumentationGateway.java | 2 + .../gateway/InstrumentationGatewayTest.java | 8 ++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java index f95e6dfaf2c..8a1af27563e 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java @@ -126,6 +126,7 @@ public class GatewayBridge { new ConcurrentHashMap<>(); private volatile DataSubscriberInfo execCmdSubInfo; private volatile DataSubscriberInfo shellCmdSubInfo; + private volatile DataSubscriberInfo requestFilesFilenamesSubInfo; public GatewayBridge( SubscriptionService subscriptionService, @@ -198,6 +199,10 @@ public void init() { subscriptionService.registerCallback( EVENTS.requestBodyProcessed(), this::onRequestBodyProcessed); } + if (additionalIGEvents.contains(EVENTS.requestFilesFilenames())) { + subscriptionService.registerCallback( + EVENTS.requestFilesFilenames(), this::onRequestFilesFilenames); + } } /** @@ -224,6 +229,7 @@ public void reset() { loginEventSubInfo.clear(); execCmdSubInfo = null; shellCmdSubInfo = null; + requestFilesFilenamesSubInfo = null; } private Flow onUser(final RequestContext ctx_, final String user) { @@ -539,6 +545,31 @@ private Flow onFileLoaded(RequestContext ctx_, String path) { } } + private Flow onRequestFilesFilenames(RequestContext ctx_, List filenames) { + AppSecRequestContext ctx = ctx_.getData(RequestContextSlot.APPSEC); + if (ctx == null || filenames == null || filenames.isEmpty()) { + return NoopFlow.INSTANCE; + } + while (true) { + DataSubscriberInfo subInfo = requestFilesFilenamesSubInfo; + if (subInfo == null) { + subInfo = producerService.getDataSubscribers(KnownAddresses.REQUEST_FILES_FILENAMES); + requestFilesFilenamesSubInfo = subInfo; + } + if (subInfo == null || subInfo.isEmpty()) { + return NoopFlow.INSTANCE; + } + DataBundle bundle = + new SingletonDataBundle<>(KnownAddresses.REQUEST_FILES_FILENAMES, filenames); + try { + GatewayContext gwCtx = new GatewayContext(false); + return producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx); + } catch (ExpiredSubscriberInfoException e) { + requestFilesFilenamesSubInfo = null; + } + } + } + private Flow onDatabaseSqlQuery(RequestContext ctx_, String sql) { AppSecRequestContext ctx = ctx_.getData(RequestContextSlot.APPSEC); if (ctx == null) { @@ -1352,6 +1383,8 @@ private static class IGAppSecEventDependencies { KnownAddresses.REQUEST_BODY_RAW, l(EVENTS.requestBodyStart(), EVENTS.requestBodyDone())); DATA_DEPENDENCIES.put(KnownAddresses.REQUEST_PATH_PARAMS, l(EVENTS.requestPathParams())); DATA_DEPENDENCIES.put(KnownAddresses.REQUEST_BODY_OBJECT, l(EVENTS.requestBodyProcessed())); + DATA_DEPENDENCIES.put( + KnownAddresses.REQUEST_FILES_FILENAMES, l(EVENTS.requestFilesFilenames())); } private static Collection> l( diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy index accab2a3365..9102de33361 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy @@ -119,6 +119,7 @@ class GatewayBridgeSpecification extends DDSpecification { BiFunction> httpClientResponseCB BiFunction> httpClientSamplingCB BiFunction> fileLoadedCB + BiFunction, Flow> requestFilesFilenamesCB BiFunction> requestSessionCB BiFunction> execCmdCB BiFunction> shellCmdCB @@ -460,7 +461,7 @@ class GatewayBridgeSpecification extends DDSpecification { void callInitAndCaptureCBs() { // force all callbacks to be registered - _ * eventDispatcher.allSubscribedDataAddresses() >> [KnownAddresses.REQUEST_PATH_PARAMS, KnownAddresses.REQUEST_BODY_OBJECT] + _ * eventDispatcher.allSubscribedDataAddresses() >> [KnownAddresses.REQUEST_PATH_PARAMS, KnownAddresses.REQUEST_BODY_OBJECT, KnownAddresses.REQUEST_FILES_FILENAMES] 1 * ig.registerCallback(EVENTS.requestStarted(), _) >> { requestStartedCB = it[1]; null @@ -552,6 +553,9 @@ class GatewayBridgeSpecification extends DDSpecification { 1 * ig.registerCallback(EVENTS.httpRoute(), _) >> { httpRouteCB = it[1]; null } + 1 * ig.registerCallback(EVENTS.requestFilesFilenames(), _) >> { + requestFilesFilenamesCB = it[1]; null + } 0 * ig.registerCallback(_, _) bridge.init() @@ -1077,6 +1081,38 @@ class GatewayBridgeSpecification extends DDSpecification { gatewayContext.isRasp == true } + void 'process request files filenames'() { + setup: + final filenames = ['malicious.php', 'document.pdf'] + eventDispatcher.getDataSubscribers({ + KnownAddresses.REQUEST_FILES_FILENAMES in it + }) >> nonEmptyDsInfo + DataBundle bundle + GatewayContext gatewayContext + + when: + Flow flow = requestFilesFilenamesCB.apply(ctx, filenames) + + then: + 1 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _ as DataBundle, _ as GatewayContext) >> { + a, b, db, gw -> bundle = db; gatewayContext = gw; NoopFlow.INSTANCE + } + bundle.get(KnownAddresses.REQUEST_FILES_FILENAMES) == filenames + flow.result == null + flow.action == Flow.Action.Noop.INSTANCE + gatewayContext.isTransient == false + gatewayContext.isRasp == false + } + + void 'process request files filenames with empty list returns noop'() { + when: + Flow flow = requestFilesFilenamesCB.apply(ctx, []) + + then: + flow == NoopFlow.INSTANCE + 0 * eventDispatcher.publishDataEvent(*_) + } + void 'process exec cmd'() { setup: final cmd = ['/bin/../usr/bin/reboot', '-f'] as String[] diff --git a/internal-api/src/main/java/datadog/trace/api/gateway/Events.java b/internal-api/src/main/java/datadog/trace/api/gateway/Events.java index 265685db8f1..0a751b637f0 100644 --- a/internal-api/src/main/java/datadog/trace/api/gateway/Events.java +++ b/internal-api/src/main/java/datadog/trace/api/gateway/Events.java @@ -7,6 +7,7 @@ import datadog.trace.api.http.StoredBodySupplier; import datadog.trace.api.telemetry.LoginEvent; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @@ -382,6 +383,19 @@ public EventType>> httpClientSamp return (EventType>>) HTTP_CLIENT_SAMPLING; } + static final int REQUEST_FILES_FILENAMES_ID = 30; + + @SuppressWarnings("rawtypes") + private static final EventType REQUEST_FILES_FILENAMES = + new ET<>("request.body.filenames", REQUEST_FILES_FILENAMES_ID); + + /** Filenames of files uploaded in a multipart/form-data request */ + @SuppressWarnings("unchecked") + public EventType, Flow>> requestFilesFilenames() { + return (EventType, Flow>>) + REQUEST_FILES_FILENAMES; + } + static final int MAX_EVENTS = nextId.get(); private static final class ET extends EventType { diff --git a/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java b/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java index 7791b382edd..e520bda0927 100644 --- a/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java +++ b/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java @@ -14,6 +14,7 @@ import static datadog.trace.api.gateway.Events.LOGIN_EVENT_ID; import static datadog.trace.api.gateway.Events.MAX_EVENTS; import static datadog.trace.api.gateway.Events.REQUEST_BODY_CONVERTED_ID; +import static datadog.trace.api.gateway.Events.REQUEST_FILES_FILENAMES_ID; import static datadog.trace.api.gateway.Events.REQUEST_BODY_DONE_ID; import static datadog.trace.api.gateway.Events.REQUEST_BODY_START_ID; import static datadog.trace.api.gateway.Events.REQUEST_CLIENT_SOCKET_ADDRESS_ID; @@ -360,6 +361,7 @@ public Flow apply(RequestContext ctx, StoredBodySupplier storedBodySupplie case GRAPHQL_SERVER_REQUEST_MESSAGE_ID: case REQUEST_BODY_CONVERTED_ID: case RESPONSE_BODY_ID: + case REQUEST_FILES_FILENAMES_ID: return (C) new BiFunction>() { @Override diff --git a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java index a0fb9794c52..5c2e4e3449f 100644 --- a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java +++ b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java @@ -236,6 +236,10 @@ public void testNormalCalls() { cbp.getCallback(events.shellCmd()).apply(null, null); ss.registerCallback(events.httpRoute(), callback); cbp.getCallback(events.httpRoute()).accept(null, null); + ss.registerCallback(events.requestFilesFilenames(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestFilesFilenames()).apply(null, null).getAction()); assertEquals(Events.MAX_EVENTS, callback.count); } @@ -322,6 +326,10 @@ public void testThrowableBlocking() { cbp.getCallback(events.shellCmd()).apply(null, null); ss.registerCallback(events.httpRoute(), throwback); cbp.getCallback(events.httpRoute()).accept(null, null); + ss.registerCallback(events.requestFilesFilenames(), throwback); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestFilesFilenames()).apply(null, null)); assertEquals(Events.MAX_EVENTS, throwback.count); } From a8177809aa9d718d1474565968b1bf09bf854a05 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Mon, 23 Mar 2026 14:32:57 +0100 Subject: [PATCH 2/6] Add server.request.body.filenames support for commons-fileupload Instrument ServletFileUpload.parseRequest() to extract filenames from non-form-field FileItems and fire the requestFilesFilenames() IG event. Co-Authored-By: Claude Sonnet 4.6 --- .../CommonsFileUploadAppSecModule.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java diff --git a/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java b/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java new file mode 100644 index 00000000000..df2a4e7ce40 --- /dev/null +++ b/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java @@ -0,0 +1,93 @@ +package datadog.trace.instrumentation.commons.fileupload; + +import static datadog.trace.api.gateway.Events.EVENTS; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +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; +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.instrumentation.api.AgentTracer; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import net.bytebuddy.asm.Advice; +import org.apache.commons.fileupload.FileItem; + +@AutoService(InstrumenterModule.class) +public class CommonsFileUploadAppSecModule extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public CommonsFileUploadAppSecModule() { + super("commons-fileupload"); + } + + @Override + public String instrumentedType() { + return "org.apache.commons.fileupload.servlet.ServletFileUpload"; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("parseRequest") + .and(isPublic()) + .and(takesArgument(0, named("javax.servlet.http.HttpServletRequest"))), + getClass().getName() + "$ParseRequestAdvice"); + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class ParseRequestAdvice { + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.Return final List fileItems, + @ActiveRequestContext RequestContext reqCtx, + @Advice.Thrown(readOnly = false) Throwable t) { + if (t != null || fileItems == null || fileItems.isEmpty()) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + + List filenames = new ArrayList<>(); + for (FileItem fileItem : fileItems) { + if (fileItem.isFormField()) { + continue; + } + String name = fileItem.getName(); + if (name != null && !name.isEmpty()) { + filenames.add(name); + } + } + if (filenames.isEmpty()) { + 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); + t = new BlockingException("Blocked request (multipart file upload)"); + reqCtx.getTraceSegment().effectivelyBlocked(); + } + } + } + } +} From 9b87129fba98cd35be4a43136d8ecd92652a7c40 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 24 Mar 2026 10:09:26 +0100 Subject: [PATCH 3/6] Add smoke test for malicious file upload filename blocking Certifies that the commons-fileupload instrumentation fires server.request.body.filenames and the WAF can block on it end-to-end: - Add /upload endpoint using ServletFileUpload.parseRequest() (mirrors client's fileupload.jsp pattern) - Disable Spring multipart auto-config so Commons FileUpload handles the request before Spring intercepts it - Add commons-fileupload:1.5 dependency to the smoke test app - Add __test_file_upload_block WAF rule matching .jsp/.php/.asp/.aspx filenames and block request based on malicious file upload filename test Co-Authored-By: Claude Sonnet 4.6 --- dd-smoke-tests/appsec/springboot/build.gradle | 3 ++ .../appsec/springboot/gradle.lockfile | 7 +-- .../springboot/controller/WebController.java | 25 +++++++++ .../src/main/resources/application.properties | 1 + .../appsec/SpringBootSmokeTest.groovy | 52 +++++++++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/dd-smoke-tests/appsec/springboot/build.gradle b/dd-smoke-tests/appsec/springboot/build.gradle index 446dbcc3da5..2b2285a9ea4 100644 --- a/dd-smoke-tests/appsec/springboot/build.gradle +++ b/dd-smoke-tests/appsec/springboot/build.gradle @@ -21,6 +21,9 @@ dependencies { implementation(group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.6.0') implementation group: 'com.h2database', name: 'h2', version: '2.1.212' + // file upload + implementation group: 'commons-fileupload', name: 'commons-fileupload', version: '1.5' + // ssrf implementation group: 'commons-httpclient', name: 'commons-httpclient', version: '2.0' implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.0' diff --git a/dd-smoke-tests/appsec/springboot/gradle.lockfile b/dd-smoke-tests/appsec/springboot/gradle.lockfile index f583fba98cd..5101e830a72 100644 --- a/dd-smoke-tests/appsec/springboot/gradle.lockfile +++ b/dd-smoke-tests/appsec/springboot/gradle.lockfile @@ -20,7 +20,6 @@ com.fasterxml.jackson.core:jackson-databind:2.13.0=compileClasspath,runtimeClass com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.module:jackson-module-parameter-names:2.13.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.13.0=compileClasspath,runtimeClasspath,testCompileClasspath,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 @@ -48,9 +47,9 @@ com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath com.squareup.okio:okio:1.6.0=compileClasspath,runtimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-codec:commons-codec:1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath +commons-fileupload:commons-fileupload:1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-httpclient:commons-httpclient:2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.20.0=spotbugs commons-lang:commons-lang:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -145,6 +144,8 @@ org.springframework:spring-expression:5.3.13=compileClasspath,runtimeClasspath,t org.springframework:spring-jcl:5.3.13=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:5.3.13=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:5.3.13=compileClasspath,runtimeClasspath,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 org.yaml:snakeyaml:1.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty=annotationProcessor,shadow,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/controller/WebController.java b/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/controller/WebController.java index 59f5d9a75dc..55efc9de0b8 100644 --- a/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/controller/WebController.java +++ b/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/controller/WebController.java @@ -20,9 +20,15 @@ import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; +import org.apache.commons.fileupload.FileItem; +import org.apache.commons.fileupload.FileUploadException; +import org.apache.commons.fileupload.disk.DiskFileItemFactory; +import org.apache.commons.fileupload.servlet.ServletFileUpload; import org.apache.commons.httpclient.HttpClient; import org.apache.commons.httpclient.HttpMethod; import org.apache.commons.httpclient.methods.GetMethod; @@ -272,6 +278,25 @@ public ResponseEntity exceedResponseHeaders() { return new ResponseEntity<>("Custom headers added", headers, HttpStatus.OK); } + @PostMapping(value = "/upload", consumes = "multipart/form-data") + public ResponseEntity upload(HttpServletRequest request) { + if (!ServletFileUpload.isMultipartContent(request)) { + return ResponseEntity.badRequest().body("Not a multipart request"); + } + try { + List items = new ServletFileUpload(new DiskFileItemFactory()).parseRequest(request); + List names = new ArrayList<>(); + for (FileItem item : items) { + if (!item.isFormField()) { + names.add(item.getName()); + } + } + return ResponseEntity.ok("Uploaded: " + names); + } catch (FileUploadException e) { + return ResponseEntity.status(500).body("Upload error: " + e.getMessage()); + } + } + @PostMapping("/waf-event-with-body") public String wafEventWithBody(@RequestBody String body) { return "EXECUTED"; diff --git a/dd-smoke-tests/appsec/springboot/src/main/resources/application.properties b/dd-smoke-tests/appsec/springboot/src/main/resources/application.properties index bac6605ce8f..a118c53c945 100644 --- a/dd-smoke-tests/appsec/springboot/src/main/resources/application.properties +++ b/dd-smoke-tests/appsec/springboot/src/main/resources/application.properties @@ -1 +1,2 @@ logging.level.root=WARN +spring.servlet.multipart.enabled=false diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy index 323d0f1d4c2..80c4bb4949c 100644 --- a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy @@ -210,6 +210,26 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { transformers: [], on_match : ['block'] ], + [ + id : '__test_file_upload_block', + name : 'test rule to block on malicious file upload filename', + tags : [ + type : 'unrestricted-file-upload', + category : 'attack_attempt', + confidence: '1', + ], + conditions : [ + [ + parameters: [ + inputs: [[address: 'server.request.body.filenames']], + regex : '\\.(?:jsp|php|asp|aspx)$', + ], + operator : 'match_regex', + ] + ], + transformers: [], + on_match : ['block'] + ], [ id : "apiA-100-001", name: "API 10 tag rule on request headers", @@ -559,6 +579,38 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } + void 'block request based on malicious file upload filename'() { + when: + String url = "http://localhost:${httpPort}/upload" + def requestBody = new okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart('file', 'exploit.jsp', + RequestBody.create(MediaType.parse('application/octet-stream'), 'webshell content')) + .build() + def request = new Request.Builder() + .url(url) + .post(requestBody) + .build() + def response = client.newCall(request).execute() + def responseBodyStr = response.body().string() + + then: + responseBodyStr.contains("blocked") + response.code() == 403 + + when: + waitForTraceCount(1) == 1 + + then: + rootSpans.size() == 1 + forEachRootSpanTrigger { + assert it['rule']['id'] == '__test_file_upload_block' + } + rootSpans.each { + assert it.meta.get('appsec.blocked') != null, 'appsec.blocked is not set' + } + } + void 'rasp reports stacktrace on sql injection'() { when: String url = "http://localhost:${httpPort}/sqli/query?id=' OR 1=1 --" From 2a10f65fd80a594bedbc59c2d3f0857403d212ef Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 24 Mar 2026 11:18:23 +0100 Subject: [PATCH 4/6] Fix smoke test multipart: exclude MultipartAutoConfiguration Spring's MultipartAutoConfiguration was activating despite spring.servlet.multipart.enabled=false in application.properties, causing StandardServletMultipartResolver to consume the request InputStream before Commons FileUpload could read it. Explicitly exclude MultipartAutoConfiguration via @SpringBootApplication so the raw InputStream is available to ServletFileUpload.parseRequest(). Co-Authored-By: Claude Sonnet 4.6 --- .../smoketest/appsec/springboot/SpringbootApplication.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/SpringbootApplication.java b/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/SpringbootApplication.java index 7c115bd857a..ae0bd8277c4 100644 --- a/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/SpringbootApplication.java +++ b/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/SpringbootApplication.java @@ -4,8 +4,9 @@ import java.lang.reflect.Field; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration; -@SpringBootApplication +@SpringBootApplication(exclude = MultipartAutoConfiguration.class) public class SpringbootApplication { public static void main(final String[] args) { From 51adb1ef21291bff4ee0ecee608b4d5e2c9f63ed Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 24 Mar 2026 11:22:03 +0100 Subject: [PATCH 5/6] Fix import ordering and named() matcher consistency - InstrumentationGateway.java: restore alphabetical import order (REQUEST_FILES_FILENAMES_ID belongs after REQUEST_ENDED_ID) - CommonsFileUploadAppSecModule.java: use NameMatchers.named instead of ElementMatchers.named, consistent with adjacent IAST instrumentation Co-Authored-By: Claude Sonnet 4.6 --- .../commons/fileupload/CommonsFileUploadAppSecModule.java | 2 +- .../java/datadog/trace/api/gateway/InstrumentationGateway.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java b/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java index df2a4e7ce40..e9cfd59be33 100644 --- a/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java +++ b/dd-java-agent/instrumentation/commons-fileupload-1.5/src/main/java/datadog/trace/instrumentation/commons/fileupload/CommonsFileUploadAppSecModule.java @@ -1,8 +1,8 @@ package datadog.trace.instrumentation.commons.fileupload; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; import static datadog.trace.api.gateway.Events.EVENTS; import static net.bytebuddy.matcher.ElementMatchers.isPublic; -import static net.bytebuddy.matcher.ElementMatchers.named; import static net.bytebuddy.matcher.ElementMatchers.takesArgument; import com.google.auto.service.AutoService; diff --git a/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java b/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java index e520bda0927..553732422a6 100644 --- a/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java +++ b/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java @@ -14,11 +14,11 @@ import static datadog.trace.api.gateway.Events.LOGIN_EVENT_ID; import static datadog.trace.api.gateway.Events.MAX_EVENTS; import static datadog.trace.api.gateway.Events.REQUEST_BODY_CONVERTED_ID; -import static datadog.trace.api.gateway.Events.REQUEST_FILES_FILENAMES_ID; import static datadog.trace.api.gateway.Events.REQUEST_BODY_DONE_ID; import static datadog.trace.api.gateway.Events.REQUEST_BODY_START_ID; import static datadog.trace.api.gateway.Events.REQUEST_CLIENT_SOCKET_ADDRESS_ID; import static datadog.trace.api.gateway.Events.REQUEST_ENDED_ID; +import static datadog.trace.api.gateway.Events.REQUEST_FILES_FILENAMES_ID; import static datadog.trace.api.gateway.Events.REQUEST_HEADER_DONE_ID; import static datadog.trace.api.gateway.Events.REQUEST_HEADER_ID; import static datadog.trace.api.gateway.Events.REQUEST_INFERRED_CLIENT_ADDRESS_ID; From f186acc614b6b3cc7ddaa445e70ab889ec86b888 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 24 Mar 2026 11:59:02 +0100 Subject: [PATCH 6/6] spotless --- .../datadog/trace/api/gateway/InstrumentationGatewayTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java index 5c2e4e3449f..47864e05063 100644 --- a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java +++ b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java @@ -328,8 +328,7 @@ public void testThrowableBlocking() { cbp.getCallback(events.httpRoute()).accept(null, null); ss.registerCallback(events.requestFilesFilenames(), throwback); assertEquals( - Flow.ResultFlow.empty(), - cbp.getCallback(events.requestFilesFilenames()).apply(null, null)); + Flow.ResultFlow.empty(), cbp.getCallback(events.requestFilesFilenames()).apply(null, null)); assertEquals(Events.MAX_EVENTS, throwback.count); }