Skip to content

Commit 37a783c

Browse files
authored
Extended appsec request body collection (#8748)
What Does This Do Adds the APPSEC_RASP_COLLECT_REQUEST_BODY flag, which enables collection of request body. This feature is disabled by default. if APPSEC_RASP_COLLECT_REQUEST_BODY is enabled and there is a RASP event put the same parsed request body that is sent to the WAF via meta_struct with http.request.body key Add listener to ObjectInstrospection#convert to add boolean tag _dd.appsec.rasp.request_body_size.exceeded if a limit is surpassed
1 parent 38ccc57 commit 37a783c

File tree

9 files changed

+253
-3
lines changed

9 files changed

+253
-3
lines changed

dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFModule.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ public void onDataAvailable(
480480
}
481481

482482
if (gwCtx.isRasp) {
483+
reqCtx.setRaspMatched(true);
483484
WafMetricCollector.get().raspRuleMatch(gwCtx.raspRuleType);
484485
}
485486

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ public final class ObjectIntrospection {
3838

3939
private ObjectIntrospection() {}
4040

41+
/**
42+
* Listener interface for optional per-call truncation logic. Single-method invoked when any
43+
* truncation occurs, receiving only the request context.
44+
*/
45+
@FunctionalInterface
46+
public interface TruncationListener {
47+
/** Called after default truncation handling if any truncation occurred. */
48+
void onTruncation();
49+
}
50+
4151
/**
4252
* Converts arbitrary objects compatible with ddwaf_object. Possible types in the result are:
4353
*
@@ -68,12 +78,26 @@ private ObjectIntrospection() {}
6878
* @return the converted object
6979
*/
7080
public static Object convert(Object obj, AppSecRequestContext requestContext) {
81+
return convert(obj, requestContext, null);
82+
}
83+
84+
/**
85+
* Core conversion method with an optional per-call truncation listener. Always applies default
86+
* truncation logic, then invokes listener if provided.
87+
*/
88+
public static Object convert(
89+
Object obj, AppSecRequestContext requestContext, TruncationListener listener) {
7190
State state = new State(requestContext);
7291
Object converted = guardedConversion(obj, 0, state);
7392
if (state.stringTooLong || state.listMapTooLarge || state.objectTooDeep) {
93+
// Default truncation handling: always run
7494
requestContext.setWafTruncated();
7595
WafMetricCollector.get()
7696
.wafInputTruncated(state.stringTooLong, state.listMapTooLarge, state.objectTooDeep);
97+
// Optional extra per-call logic: only requestContext is passed
98+
if (listener != null) {
99+
listener.onTruncation();
100+
}
77101
}
78102
return converted;
79103
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ public class AppSecRequestContext implements DataBundle, Closeable {
129129
private volatile int wafTimeouts;
130130
private volatile int raspTimeouts;
131131

132+
private volatile Object processedRequestBody;
133+
private volatile boolean raspMatched;
134+
132135
// keep a reference to the last published usr.id
133136
private volatile String userId;
134137
// keep a reference to the last published usr.login
@@ -675,4 +678,20 @@ public boolean isWafContextClosed() {
675678
void setRequestEndCalled() {
676679
requestEndCalled = true;
677680
}
681+
682+
public void setProcessedRequestBody(Object processedRequestBody) {
683+
this.processedRequestBody = processedRequestBody;
684+
}
685+
686+
public Object getProcessedRequestBody() {
687+
return processedRequestBody;
688+
}
689+
690+
public boolean isRaspMatched() {
691+
return raspMatched;
692+
}
693+
694+
public void setRaspMatched(boolean raspMatched) {
695+
this.raspMatched = raspMatched;
696+
}
678697
}

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public class GatewayBridge {
7777
private static final String USER_COLLECTION_MODE_TAG = "_dd.appsec.user.collection_mode";
7878

7979
private static final Map<LoginEvent, Address<?>> EVENT_MAPPINGS = new EnumMap<>(LoginEvent.class);
80+
private static final String METASTRUCT_REQUEST_BODY = "http.request.body";
8081

8182
static {
8283
EVENT_MAPPINGS.put(LoginEvent.LOGIN_SUCCESS, KnownAddresses.LOGIN_SUCCESS);
@@ -572,9 +573,20 @@ private Flow<Void> onRequestBodyProcessed(RequestContext ctx_, Object obj) {
572573
if (subInfo == null || subInfo.isEmpty()) {
573574
return NoopFlow.INSTANCE;
574575
}
575-
DataBundle bundle =
576-
new SingletonDataBundle<>(
577-
KnownAddresses.REQUEST_BODY_OBJECT, ObjectIntrospection.convert(obj, ctx));
576+
Object converted =
577+
ObjectIntrospection.convert(
578+
obj,
579+
ctx,
580+
() -> {
581+
if (Config.get().isAppSecRaspCollectRequestBody()) {
582+
ctx_.getTraceSegment()
583+
.setTagTop("_dd.appsec.rasp.request_body_size.exceeded", true);
584+
}
585+
});
586+
if (Config.get().isAppSecRaspCollectRequestBody()) {
587+
ctx.setProcessedRequestBody(converted);
588+
}
589+
DataBundle bundle = new SingletonDataBundle<>(KnownAddresses.REQUEST_BODY_OBJECT, converted);
578590
try {
579591
GatewayContext gwCtx = new GatewayContext(false);
580592
return producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx);
@@ -723,6 +735,12 @@ private NoopFlow onRequestEnded(RequestContext ctx_, IGSpanInfo spanInfo) {
723735
StackUtils.addStacktraceEventsToMetaStruct(ctx_, METASTRUCT_EXPLOIT, stackTraces);
724736
}
725737

738+
// Report collected parsed request body if there is a RASP event
739+
if (ctx.isRaspMatched() && ctx.getProcessedRequestBody() != null) {
740+
ctx_.getOrCreateMetaStructTop(
741+
METASTRUCT_REQUEST_BODY, k -> ctx.getProcessedRequestBody());
742+
}
743+
726744
} else if (hasUserInfo(traceSeg)) {
727745
// Report all collected request headers on user tracking event
728746
writeRequestHeaders(traceSeg, REQUEST_HEADERS_ALLOW_LIST, ctx.getRequestHeaders(), false);

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,18 @@ class ObjectIntrospectionSpecification extends DDSpecification {
304304
expect:
305305
convert([cs], ctx) == ['error:my exception']
306306
}
307+
308+
void 'truncated conversion triggers truncation listener if available '() {
309+
setup:
310+
def listener = Mock(ObjectIntrospection.TruncationListener)
311+
def object = 'A' * 5000
312+
313+
when:
314+
convert(object, ctx, listener)
315+
316+
then:
317+
1 * ctx.setWafTruncated()
318+
1 * wafMetricCollector.wafInputTruncated(true, false, false)
319+
1 * listener.onTruncation()
320+
}
307321
}

dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,63 @@
11
package datadog.smoketest.appsec
22

33
import datadog.trace.agent.test.utils.OkHttpUtils
4+
import okhttp3.FormBody
45
import okhttp3.Request
6+
import spock.lang.Shared
57

68
class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest {
79

10+
@Shared
11+
String buildDir = new File(System.getProperty("datadog.smoketest.builddir")).absolutePath
12+
@Shared
13+
String customRulesPath = "${buildDir}/appsec_custom_rules.json"
14+
15+
def prepareCustomRules() {
16+
// Prepare ruleset with additional test rules
17+
mergeRules(
18+
customRulesPath,
19+
[
20+
[
21+
id : 'rasp-932-100', // to replace default rule
22+
name : 'Shell command injection exploit',
23+
enable : 'true',
24+
tags : [
25+
type: 'command_injection',
26+
category: 'vulnerability_trigger',
27+
cwe: '77',
28+
capec: '1000/152/248/88',
29+
confidence: '0',
30+
module: 'rasp'
31+
],
32+
conditions : [
33+
[
34+
parameters: [
35+
resource: [[address: 'server.sys.shell.cmd']],
36+
params : [[address: 'server.request.body']],
37+
],
38+
operator : "shi_detector",
39+
],
40+
],
41+
transformers: [],
42+
on_match : ['block']
43+
]
44+
])
45+
}
46+
847
@Override
948
ProcessBuilder createProcessBuilder() {
1049

50+
// We run this here to ensure it runs before starting the process. Child setupSpec runs after parent setupSpec,
51+
// so it is not a valid location.
52+
prepareCustomRules()
53+
1154
String springBootShadowJar = System.getProperty("datadog.smoketest.appsec.springboot.shadowJar.path")
1255

1356
List<String> command = new ArrayList<>()
1457
command.add(javaPath())
1558
command.addAll(defaultJavaProperties)
1659
command.addAll(defaultAppSecProperties)
60+
command.add('-Ddd.appsec.rasp.collect.request.body=true')
1761
command.add('-Ddd.appsec.collect.all.headers=true')
1862
command.add('-Ddd.appsec.header.collection.redaction.enabled=false')
1963
command.addAll((String[]) ["-jar", springBootShadowJar, "--server.port=${httpPort}"])
@@ -146,6 +190,124 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest {
146190
rootSpan.meta.get('http.response.headers.content-language') == 'en-US'
147191
}
148192

193+
void 'test request body collection if RASP event'(){
194+
when:
195+
String url = "http://localhost:${httpPort}/shi/cmd"
196+
def formBuilder = new FormBody.Builder()
197+
formBuilder.add('cmd', '$(cat /etc/passwd 1>&2 ; echo .)')
198+
final body = formBuilder.build()
199+
def request = new Request.Builder()
200+
.url(url)
201+
.post(body)
202+
.build()
203+
def response = client.newCall(request).execute()
204+
def responseBodyStr = response.body().string()
205+
206+
then:
207+
response.code() == 403
208+
responseBodyStr.contains('You\'ve been blocked')
209+
210+
when:
211+
waitForTraceCount(1)
212+
213+
then:
214+
def rootSpans = this.rootSpans.toList()
215+
rootSpans.size() == 1
216+
def rootSpan = rootSpans[0]
217+
218+
def trigger = null
219+
for (t in rootSpan.triggers) {
220+
if (t['rule']['id'] == 'rasp-932-100') {
221+
trigger = t
222+
break
223+
}
224+
}
225+
assert trigger != null, 'test trigger not found'
226+
227+
rootSpan.span.metaStruct != null
228+
def requestBody = rootSpan.span.metaStruct.get('http.request.body')
229+
assert requestBody != null, 'request body is not set'
230+
!rootSpan.meta.containsKey('_dd.appsec.rasp.request_body_size.exceeded')
231+
232+
}
233+
234+
void 'test request body collection if RASP event exceeded'(){
235+
when:
236+
String url = "http://localhost:${httpPort}/shi/cmd"
237+
def formBuilder = new FormBody.Builder()
238+
formBuilder.add('cmd', '$(cat /etc/passwd 1>&2 ; echo .)'+'A' * 5000)
239+
final body = formBuilder.build()
240+
def request = new Request.Builder()
241+
.url(url)
242+
.post(body)
243+
.build()
244+
def response = client.newCall(request).execute()
245+
def responseBodyStr = response.body().string()
246+
247+
then:
248+
response.code() == 403
249+
responseBodyStr.contains('You\'ve been blocked')
250+
251+
when:
252+
waitForTraceCount(1)
253+
254+
then:
255+
def rootSpans = this.rootSpans.toList()
256+
rootSpans.size() == 1
257+
def rootSpan = rootSpans[0]
258+
259+
def trigger = null
260+
for (t in rootSpan.triggers) {
261+
if (t['rule']['id'] == 'rasp-932-100') {
262+
trigger = t
263+
break
264+
}
265+
}
266+
assert trigger != null, 'test trigger not found'
267+
268+
rootSpan.span.metaStruct != null
269+
def requestBody = rootSpan.span.metaStruct.get('http.request.body')
270+
assert requestBody != null, 'request body is not set'
271+
rootSpan.meta.containsKey('_dd.appsec.rasp.request_body_size.exceeded')
272+
273+
}
274+
275+
void 'test request body not collected if no RASP event'(){
276+
when:
277+
String url = "http://localhost:${httpPort}/greeting"
278+
def formBuilder = new FormBody.Builder()
279+
formBuilder.add('cmd', 'test')
280+
final body = formBuilder.build()
281+
def request = new Request.Builder()
282+
.url(url)
283+
.post(body)
284+
.build()
285+
def response = client.newCall(request).execute()
286+
287+
then:
288+
response.code() == 200
289+
290+
when:
291+
waitForTraceCount(1)
292+
293+
then:
294+
def rootSpans = this.rootSpans.toList()
295+
rootSpans.size() == 1
296+
def rootSpan = rootSpans[0]
297+
298+
def trigger = null
299+
for (t in rootSpan.triggers) {
300+
if (t['rule']['id'] == 'rasp-932-100') {
301+
trigger = t
302+
break
303+
}
304+
}
305+
assert trigger == null, 'test trigger found'
306+
307+
rootSpan.span.metaStruct == null
308+
309+
}
310+
149311

150312

151313
}

dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest {
469469
}
470470
}
471471
assert trigger != null, 'test trigger not found'
472+
rootSpan.span.metaStruct == null
472473

473474
where:
474475
variant | _
@@ -508,6 +509,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest {
508509
}
509510
}
510511
assert trigger != null, 'test trigger not found'
512+
rootSpan.span.metaStruct == null
511513

512514
where:
513515
variant | _
@@ -600,6 +602,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest {
600602
}
601603
}
602604
assert trigger != null, 'test trigger not found'
605+
rootSpan.span.metaStruct == null
603606

604607
where:
605608
endpoint | cmd | params
@@ -650,6 +653,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest {
650653
}
651654
}
652655
assert trigger != null, 'test trigger not found'
656+
rootSpan.span.metaStruct == null
653657

654658
where:
655659
endpoint | cmd | params

dd-trace-api/src/main/java/datadog/trace/api/config/AppSecConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public final class AppSecConfig {
4747
public static final String APPSEC_MAX_COLLECTED_HEADERS = "appsec.max.collected.headers";
4848
public static final String APPSEC_HEADER_COLLECTION_REDACTION_ENABLED =
4949
"appsec.header.collection.redaction.enabled";
50+
public static final String APPSEC_RASP_COLLECT_REQUEST_BODY = "appsec.rasp.collect.request.body";
5051

5152
private AppSecConfig() {}
5253
}

0 commit comments

Comments
 (0)