Skip to content

Commit 33212a8

Browse files
committed
feat(appsec): expose uploaded file content as new WAF address
Adds `server.request.body.files_content` address to expose the content of uploaded files for deeper content-based WAF inspection rules. - New `REQUEST_FILES_CONTENT` address in KnownAddresses - New `requestFilesContent` event (ID 31) in Events.java - GatewayBridge handler that publishes file contents to the WAF - Content extraction in Jetty 9.3/9.4/11, Tomcat 7, and Liberty 20 instrumentations: reads up to 4 KB per file as ISO-8859-1 string, positionally aligned with REQUEST_FILES_FILENAMES - File content is only read after filenames event is fired; if filenames already caused a block the content event is skipped - Unit tests for MultipartHelper, ParameterCollector, GatewayBridge, KnownAddresses APPSEC-61875
1 parent 2b2f46d commit 33212a8

File tree

19 files changed

+886
-148
lines changed

19 files changed

+886
-148
lines changed

dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/KnownAddresses.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ public interface KnownAddresses {
7474
Address<Long> REQUEST_COMBINED_FILE_SIZE =
7575
new Address<>("server.request.body.combined_file_size");
7676

77+
/**
78+
* Contains the content of each uploaded file in a multipart/form-data request. Each entry in the
79+
* list corresponds positionally to {@link #REQUEST_FILES_FILENAMES}. Content is truncated to a
80+
* maximum size to avoid excessive memory usage. Available only on inspected multipart/form-data
81+
* requests.
82+
*/
83+
Address<List<String>> REQUEST_FILES_CONTENT = new Address<>("server.request.body.files_content");
84+
7785
/**
7886
* The parsed query string.
7987
*
@@ -205,6 +213,8 @@ static Address<?> forName(String name) {
205213
return REQUEST_FILES_FILENAMES;
206214
case "server.request.body.combined_file_size":
207215
return REQUEST_COMBINED_FILE_SIZE;
216+
case "server.request.body.files_content":
217+
return REQUEST_FILES_CONTENT;
208218
case "server.request.query":
209219
return REQUEST_QUERY;
210220
case "server.request.headers.no_cookies":

dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public class GatewayBridge {
131131
private volatile DataSubscriberInfo execCmdSubInfo;
132132
private volatile DataSubscriberInfo shellCmdSubInfo;
133133
private volatile DataSubscriberInfo requestFilesFilenamesSubInfo;
134+
private volatile DataSubscriberInfo requestFilesContentSubInfo;
134135

135136
public GatewayBridge(
136137
SubscriptionService subscriptionService,
@@ -208,6 +209,10 @@ public void init() {
208209
subscriptionService.registerCallback(
209210
EVENTS.requestFilesFilenames(), this::onRequestFilesFilenames);
210211
}
212+
if (additionalIGEvents.contains(EVENTS.requestFilesContent())) {
213+
subscriptionService.registerCallback(
214+
EVENTS.requestFilesContent(), this::onRequestFilesContent);
215+
}
211216
}
212217

213218
/**
@@ -235,6 +240,7 @@ public void reset() {
235240
execCmdSubInfo = null;
236241
shellCmdSubInfo = null;
237242
requestFilesFilenamesSubInfo = null;
243+
requestFilesContentSubInfo = null;
238244
}
239245

240246
private Flow<Void> onUser(final RequestContext ctx_, final String user) {
@@ -605,6 +611,31 @@ private Flow<Void> onRequestFilesFilenames(RequestContext ctx_, List<String> fil
605611
}
606612
}
607613

614+
private Flow<Void> onRequestFilesContent(RequestContext ctx_, List<String> filesContent) {
615+
AppSecRequestContext ctx = ctx_.getData(RequestContextSlot.APPSEC);
616+
if (ctx == null || filesContent == null || filesContent.isEmpty()) {
617+
return NoopFlow.INSTANCE;
618+
}
619+
while (true) {
620+
DataSubscriberInfo subInfo = requestFilesContentSubInfo;
621+
if (subInfo == null) {
622+
subInfo = producerService.getDataSubscribers(KnownAddresses.REQUEST_FILES_CONTENT);
623+
requestFilesContentSubInfo = subInfo;
624+
}
625+
if (subInfo == null || subInfo.isEmpty()) {
626+
return NoopFlow.INSTANCE;
627+
}
628+
DataBundle bundle =
629+
new SingletonDataBundle<>(KnownAddresses.REQUEST_FILES_CONTENT, filesContent);
630+
try {
631+
GatewayContext gwCtx = new GatewayContext(false);
632+
return producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx);
633+
} catch (ExpiredSubscriberInfoException e) {
634+
requestFilesContentSubInfo = null;
635+
}
636+
}
637+
}
638+
608639
private Flow<Void> onDatabaseSqlQuery(RequestContext ctx_, String sql) {
609640
AppSecRequestContext ctx = ctx_.getData(RequestContextSlot.APPSEC);
610641
if (ctx == null) {
@@ -1464,6 +1495,7 @@ private static class IGAppSecEventDependencies {
14641495
DATA_DEPENDENCIES.put(KnownAddresses.REQUEST_BODY_OBJECT, l(EVENTS.requestBodyProcessed()));
14651496
DATA_DEPENDENCIES.put(
14661497
KnownAddresses.REQUEST_FILES_FILENAMES, l(EVENTS.requestFilesFilenames()));
1498+
DATA_DEPENDENCIES.put(KnownAddresses.REQUEST_FILES_CONTENT, l(EVENTS.requestFilesContent()));
14671499
}
14681500

14691501
private static Collection<datadog.trace.api.gateway.EventType<?>> l(

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/event/data/KnownAddressesSpecificationForkedTest.groovy

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class KnownAddressesSpecificationForkedTest extends Specification {
2626
'server.request.body.files_field_names',
2727
'server.request.body.filenames',
2828
'server.request.body.combined_file_size',
29+
'server.request.body.files_content',
2930
'server.request.query',
3031
'server.request.headers.no_cookies',
3132
'grpc.server.method',
@@ -58,7 +59,7 @@ class KnownAddressesSpecificationForkedTest extends Specification {
5859

5960
void 'number of known addresses is expected number'() {
6061
expect:
61-
Address.instanceCount() == 46
62+
Address.instanceCount() == 47
6263
KnownAddresses.WAF_CONTEXT_PROCESSOR.serial == Address.instanceCount() - 1
6364
}
6465
}

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeIGRegistrationSpecification.groovy

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,15 @@ class GatewayBridgeIGRegistrationSpecification extends DDSpecification {
3434
then:
3535
1 * ig.registerCallback(Events.REQUEST_BODY_DONE, _)
3636
}
37+
38+
void 'requestFilesContent is registered via data address'() {
39+
given:
40+
1 * eventDispatcher.allSubscribedDataAddresses() >> [KnownAddresses.REQUEST_FILES_CONTENT]
41+
42+
when:
43+
bridge.init()
44+
45+
then:
46+
1 * ig.registerCallback(Events.get().requestFilesContent(), _)
47+
}
3748
}

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class GatewayBridgeSpecification extends DDSpecification {
123123
BiFunction<RequestContext, String, Flow<Void>> fileLoadedCB
124124
BiFunction<RequestContext, String, Flow<Void>> fileWrittenCB
125125
BiFunction<RequestContext, List<String>, Flow<Void>> requestFilesFilenamesCB
126+
BiFunction<RequestContext, List<String>, Flow<Void>> requestFilesContentCB
126127
BiFunction<RequestContext, String, Flow<Void>> requestSessionCB
127128
BiFunction<RequestContext, String[], Flow<Void>> execCmdCB
128129
BiFunction<RequestContext, String, Flow<Void>> shellCmdCB
@@ -463,7 +464,7 @@ class GatewayBridgeSpecification extends DDSpecification {
463464

464465
void callInitAndCaptureCBs() {
465466
// force all callbacks to be registered
466-
_ * eventDispatcher.allSubscribedDataAddresses() >> [KnownAddresses.REQUEST_PATH_PARAMS, KnownAddresses.REQUEST_BODY_OBJECT, KnownAddresses.REQUEST_FILES_FILENAMES]
467+
_ * eventDispatcher.allSubscribedDataAddresses() >> [KnownAddresses.REQUEST_PATH_PARAMS, KnownAddresses.REQUEST_BODY_OBJECT, KnownAddresses.REQUEST_FILES_FILENAMES, KnownAddresses.REQUEST_FILES_CONTENT]
467468

468469
1 * ig.registerCallback(EVENTS.requestStarted(), _) >> {
469470
requestStartedCB = it[1]; null
@@ -561,6 +562,9 @@ class GatewayBridgeSpecification extends DDSpecification {
561562
1 * ig.registerCallback(EVENTS.requestFilesFilenames(), _) >> {
562563
requestFilesFilenamesCB = it[1]; null
563564
}
565+
1 * ig.registerCallback(EVENTS.requestFilesContent(), _) >> {
566+
requestFilesContentCB = it[1]; null
567+
}
564568
0 * ig.registerCallback(_, _)
565569

566570
bridge.init()
@@ -1142,6 +1146,38 @@ class GatewayBridgeSpecification extends DDSpecification {
11421146
0 * eventDispatcher.publishDataEvent(*_)
11431147
}
11441148

1149+
void 'process request files content'() {
1150+
setup:
1151+
final filesContent = ['%PDF-1.4 malicious content', '#!/bin/bash\nrm -rf /']
1152+
eventDispatcher.getDataSubscribers({
1153+
KnownAddresses.REQUEST_FILES_CONTENT in it
1154+
}) >> nonEmptyDsInfo
1155+
DataBundle bundle
1156+
GatewayContext gatewayContext
1157+
1158+
when:
1159+
Flow<?> flow = requestFilesContentCB.apply(ctx, filesContent)
1160+
1161+
then:
1162+
1 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _ as DataBundle, _ as GatewayContext) >> {
1163+
a, b, db, gw -> bundle = db; gatewayContext = gw; NoopFlow.INSTANCE
1164+
}
1165+
bundle.get(KnownAddresses.REQUEST_FILES_CONTENT) == filesContent
1166+
flow.result == null
1167+
flow.action == Flow.Action.Noop.INSTANCE
1168+
gatewayContext.isTransient == false
1169+
gatewayContext.isRasp == false
1170+
}
1171+
1172+
void 'process request files content with empty list returns noop'() {
1173+
when:
1174+
Flow<?> flow = requestFilesContentCB.apply(ctx, [])
1175+
1176+
then:
1177+
flow == NoopFlow.INSTANCE
1178+
0 * eventDispatcher.publishDataEvent(*_)
1179+
}
1180+
11451181
void 'process exec cmd'() {
11461182
setup:
11471183
final cmd = ['/bin/../usr/bin/reboot', '-f'] as String[]

dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package datadog.trace.instrumentation.jetty11;
22

33
import jakarta.servlet.http.Part;
4+
import java.io.IOException;
5+
import java.io.InputStream;
6+
import java.nio.charset.StandardCharsets;
47
import java.util.ArrayList;
58
import java.util.Collection;
69
import java.util.Collections;
710
import java.util.List;
811

912
public class MultipartHelper {
1013

14+
/** Maximum number of bytes read per uploaded file for WAF content inspection. */
15+
static final int MAX_FILE_CONTENT_BYTES = 4096;
16+
1117
private MultipartHelper() {}
1218

1319
/**
@@ -29,4 +35,43 @@ public static List<String> extractFilenames(Collection<Part> parts) {
2935
}
3036
return filenames;
3137
}
38+
39+
/**
40+
* Extracts the content of each uploaded file (up to {@link #MAX_FILE_CONTENT_BYTES} bytes) from a
41+
* collection of multipart {@link Part}s. Only file parts (those with a non-empty submitted
42+
* filename) are included. The returned list corresponds positionally to the list returned by
43+
* {@link #extractFilenames(Collection)}.
44+
*
45+
* @return list of file contents as ISO-8859-1 strings; never {@code null}, may be empty
46+
*/
47+
public static List<String> extractFilesContent(Collection<Part> parts) {
48+
if (parts == null || parts.isEmpty()) {
49+
return Collections.emptyList();
50+
}
51+
List<String> contents = new ArrayList<>();
52+
for (Part part : parts) {
53+
String filename = part.getSubmittedFileName();
54+
if (filename == null || filename.isEmpty()) {
55+
continue;
56+
}
57+
contents.add(readPartContent(part));
58+
}
59+
return contents;
60+
}
61+
62+
private static String readPartContent(Part part) {
63+
try {
64+
InputStream is = part.getInputStream();
65+
byte[] buf = new byte[MAX_FILE_CONTENT_BYTES];
66+
int total = 0;
67+
int n;
68+
while (total < MAX_FILE_CONTENT_BYTES
69+
&& (n = is.read(buf, total, MAX_FILE_CONTENT_BYTES - total)) != -1) {
70+
total += n;
71+
}
72+
return new String(buf, 0, total, StandardCharsets.ISO_8859_1);
73+
} catch (IOException ignored) {
74+
return "";
75+
}
76+
}
3277
}

0 commit comments

Comments
 (0)