8000 Fix Jackson nodes introspection for request/response schema extraction · DataDog/dd-trace-java@119f166 · GitHub
[go: up one dir, main page]

Skip to content 8000

Commit 119f166

Browse files
Fix Jackson nodes introspection for request/response schema extraction
1 parent 0b95f68 commit 119f166

File tree

5 files changed

+329
-1
lines changed

5 files changed

+329
-1
lines changed

dd-java-agent/appsec/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies {
2424
testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '2.2'
2525
testImplementation group: 'com.flipkart.zjsonpatch', name: 'zjsonpatch', version: '0.4.11'
2626
testImplementation libs.logback.classic
27+
testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.16.0'
2728

2829
testFixturesApi project(':dd-java-agent:testing')
2930
}

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

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import com.datadog.appsec.gateway.AppSecRequestContext;
44
import datadog.trace.api.Platform;
55
import datadog.trace.api.telemetry.WafMetricCollector;
6+
import datadog.trace.util.MethodHandles;
7+
import java.lang.invoke.MethodHandle;
68
import java.lang.reflect.Array;
79
import java.lang.reflect.Field;
810
import java.lang.reflect.InvocationTargetException;
911
import java.lang.reflect.Method;
1012
import java.lang.reflect.Modifier;
1113
import java.util.ArrayList;
1214
import java.util.HashMap;
15+
import java.util.Iterator;
1316
import java.util.List;
1417
import java.util.Map;
1518
import org.slf4j.Logger;
@@ -178,6 +181,19 @@ private static Object doConversion(Object obj, int depth, State state) {
178181
return obj.toString();
179182
}
180183

184+
// Jackson databind nodes (via reflection)
185+
Class<?> clazz = obj.getClass();
186+
if (clazz.getName().startsWith("com.fasterxml.jackson.databind.node.")) {
187+
try {
188+
return doConversionJacksonNode(
189+
new JacksonContext(clazz.getClassLoader()), obj, depth, state);
190+
} catch (Throwable e) {
191+
// in case of failure let default conversion run
192+
log.debug("Error handling jackson node {}", clazz, e);
193+
return null;
194+
}
195+
}
196+
181197
// maps
182198
if (obj instanceof Map) {
183199
Map<Object, Object> newMap = new HashMap<>((int) Math.ceil(((Map) obj).size() / .75));
@@ -212,7 +228,6 @@ private static Object doConversion(Object obj, int depth, State state) {
212228
}
213229

214230
// arrays
215-
Class<?> clazz = obj.getClass();
216231
if (clazz.isArray()) {
217232
int length = Array.getLength(obj);
218233
List<Object> newList = new ArrayList<>(length);
@@ -305,4 +320,139 @@ private static String checkStringLength(final String str, final State state) {
305320
}
306321
return str;
307322
}
323+
324+
/**
325+
* Converts Jackson databind JsonNode objects to WAF-compatible data structures using reflection.
326+
*
327+
* <p>Jackson databind objects ({@link com.fasterxml.jackson.databind.JsonNode}) implement
328+
* iterable interfaces which interferes with the standard object introspection logic. This method
329+
* bypasses that by using reflection to directly access JsonNode internals and convert them to
330+
* appropriate data types.
331+
*
332+
* <p>Supported JsonNode types and their conversions:
333+
*
334+
* <ul>
335+
* <li>{@code OBJECT} - Converted to {@link HashMap} with string keys and recursively converted
336+
* values
337+
* <li>{@code ARRAY} - Converted to {@link ArrayList} with recursively converted elements
338+
* <li>{@code STRING} - Extracted as {@link String}, subject to length truncation
339+
* <li>{@code NUMBER} - Extracted as the appropriate {@link Number} subtype (Integer, Long,
340+
* Double, etc.)
341+
* <li>{@code BOOLEAN} - Extracted as {@link Boolean}
342+
* <li>{@code NULL}, {@code MISSING}, {@code BINARY}, {@code POJO} - Converted to {@code null}
343+
* </ul>
344+
*
345+
* <p>The method applies the same truncation limits as the main conversion logic:
346+
*/
347+
private static Object doConversionJacksonNode(
348+
final JacksonContext ctx, final Object node, final int depth, final State state)
349+
throws Throwable {
350+
if (node == null) {
351+
return null;
352+
}
353+
state.elemsLeft--;
354+
if (state.elemsLeft <= 0) {
355+
state.listMapTooLarge = true;
356+
return null;
357+
}
358+
if (depth > MAX_DEPTH) {
359+
state.objectTooDeep = true;
360+
return null;
361+
}
362+
final String type = ctx.getNodeType(node);
363+
if (type == null) {
364+
return null;
365+
}
366+
switch (type) {
367+
case "OBJECT":
368+
final Map<Object, Object> newMap = new HashMap<>(ctx.getSize(node));
369+
for (Iterator<String> names = ctx.getFieldNames(node); names.hasNext(); ) {
370+
final String key = names.next();
371+
final Object newKey = keyConversion(key, state);
372+
if (newKey == null && key != null) {
373+
// probably we're out of elements anyway
374+
continue;
375+
}
10000 376+
final Object value = ctx.getField(node, key);
377+
newMap.put(newKey, doConversionJacksonNode(ctx, value, depth + 1, state));
378+
}
379+
return newMap;
380+
case "ARRAY":
381+
final List<Object> newList = new ArrayList<>(ctx.getSize(node));
382+
for (Object o : ((Iterable<?>) node)) {
383+
if (state.elemsLeft <= 0) {
384+
state.listMapTooLarge = true;
385+
break;
386+
}
387+
newList.add(doConversionJacksonNode(ctx, o, depth + 1, state));
388+
}
389+
return newList;
390+
case "BOOLEAN":
391+
return ctx.getBooleanValue(node);
392+
case "NUMBER":
393+
return ctx.getNumberValue(node);
394+
case "STRING":
395+
return checkStringLength(ctx.getTextValue(node), state);
396+
default:
397+
// return null for the rest
398+
return null;
399+
}
400+
}
401+
402+
/**
403+
* Context class used to cache method resolutions while converting a top level json node class.
404+
*/
405+
private static class JacksonContext {
406+
private final MethodHandles handles;
407+
private final Class<?> jsonNode;
408+
private MethodHandle nodeType;
409+
private MethodHandle size;
410+
private MethodHandle fieldNames;
411+
private MethodHandle fieldValue;
412+
private MethodHandle textValue;
413+
private MethodHandle booleanValue;
414+
private MethodHandle numberValue;
415+
416+
private JacksonContext(final ClassLoader cl) throws ClassNotFoundException {
417+
handles = new MethodHandles(cl);
418+
jsonNode = cl.loadClass("com.fasterxml.jackson.databind.JsonNode");
419+
}
420+
421+
private String getNodeType(final Object node) throws Throwable {
422+
nodeType = nodeType == null ? handles.method(jsonNode, "getNodeType") : nodeType;
423+
final Enum<?> type = (Enum<?>) nodeType.invoke(node);
424+
return type == null ? null : type.name();
425+
}
426+
427+
private int getSize(final Object node) throws Throwable {
428+
size = size == null ? handles.method(jsonNode, "size") : size;
429+
return (int) size.invoke(node);
430+
}
431+
432+
@SuppressWarnings("unchecked")
433+
private Iterator<String> getFieldNames(final Object node) throws Throwable {
434+
fieldNames = fieldNames == null ? handles.method(jsonNode, "fieldNames") : fieldNames;
435+
return (Iterator<String>) fieldNames.invoke(node);
436+
}
437+
438+
private Object getField(final Object node, final String name) throws Throwable {
439+
fieldValue = fieldValue == null ? handles.method(jsonNode, "get", String.class) : fieldValue;
440+
return fieldValue.invoke(node, name);
441+
}
442+
443+
private String getTextValue(final Object node) throws Throwable {
444+
textValue = textValue == null ? handles.method(jsonNode, "textValue") : textValue;
445+
return (String) textValue.invoke(node);
446+
}
447+
448+
private Number getNumberValue(final Object node) throws Throwable {
449+
numberValue = numberValue == null ? handles.method(jsonNode, "numberValue") : numberValue;
450+
return (Number) numberValue.invoke(node);
451+
}
452+
453+
private Boolean getBooleanValue(final Object node) throws Throwable {
454+
booleanValue = booleanValue == null ? handles.method(jsonNode, "booleanValue") : booleanValue;
455+
return (Boolean) booleanValue.invoke(node);
456+
}
457+
}
308458
}

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package com.datadog.appsec.event.data
22

33
import com.datadog.appsec.gateway.AppSecRequestContext
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import com.fasterxml.jackson.databind.node.ArrayNode
6+
import com.fasterxml.jackson.databind.node.ObjectNode
47
import datadog.trace.api.telemetry.WafMetricCollector
58
import datadog.trace.test.util.DDSpecification
9+
import groovy.json.JsonBuilder
10+
import groovy.json.JsonOutput
11+
import groovy.json.JsonSlurper
612
import spock.lang.Shared
713

814
import java.nio.CharBuffer
@@ -14,6 +20,9 @@ class ObjectIntrospectionSpecification extends DDSpecification {
1420
@Shared
1521
protected static final ORIGINAL_METRIC_COLLECTOR = WafMetricCollector.get()
1622

23+
@Shared
24+
protected static final MAPPER = new ObjectMapper()
25+
1726
AppSecRequestContext ctx = Mock(AppSecRequestContext)
1827

1928
WafMetricCollector wafMetricCollector = Mock(WafMetricCollector)
@@ -318,4 +327,135 @@ class ObjectIntrospectionSpecification extends DDSpecification {
318327
1 * wafMetricCollector.wafInputTruncated(true, false, false)
319328
1 * listener.onTruncation()
320329
}
330+
331+
void 'jackson node types comprehensive coverage'() {
332+
when:
333+
final result = convert(input, ctx)
334+
335+
then:
336+
result == expected
337+
338+
where:
339+
input || expected
340+
MAPPER.readTree('null') || null
341+
MAPPER.readTree('true') || true
342+
MAPPER.readTree('false') || false
343+
MAPPER.readTree('42') || 42
344+
MAPPER.readTree('3.14') || 3.14
345+
MAPPER.readTree('"hello"') || 'hello'
346+
MAPPER.readTree('[]') || []
347+
MAPPER.readTree('{}') || [:]
348+
MAPPER.readTree('[1, 2, 3]') || [1, 2, 3]
349+
MAPPER.readTree('{"key": "value"}') || [key: 'value']
350+
}
351+
352+
void 'jackson nested structures'() {
353+
when:
354+
final result = convert(input, ctx)
355+
356+
then:
357+
result == expected
358+
359+
where:
360+
input || expected
361+
MAPPER.readTree('{"a": {"b": {"c": 123}}}') || [a: [b: [c: 123]]]
362+
MAPPER.readTree('[[[1, 2]], [[3, 4]]]') || [[[1, 2]], [[3, 4]]]
363+
MAPPER.readTree('{"arr": [1, null, true]}') || [arr: [1, null, true]]
364+
MAPPER.readTree('[{"x": 1}, {"y": 2}]') || [[x: 1], [y: 2]]
365+
}
366+
367+
void 'jackson edge cases'() {
368+
when:
369+
final result = convert(input, ctx)
370+
371+
then:
372+
result == expected
373+
374+
where:
375+
input || expected
376+
MAPPER.readTree('""') || ''
377+
MAPPER.readTree('0') || 0
378+
MAPPER.readTree('-1') || -1
379+
MAPPER.readTree('9223372036854775807') || 9223372036854775807L // Long.MAX_VALUE
380+
MAPPER.readTree('1.7976931348623157E308') || 1.7976931348623157E308d // Double.MAX_VALUE
381+
MAPPER.readTree('{"": "empty_key"}') || ['': 'empty_key']
382+
MAPPER.readTree('{"null_value": null}') || [null_value: null]
383+
}
384+
385+
void 'jackson string truncation'() {
386+
setup:
387+
final longString = 'A' * (ObjectIntrospection.MAX_STRING_LENGTH + 1)
388+
final jsonInput = '{"long": "' + longString + '"}'
389+
390+
when:
391+
convert(MAPPER.readTree(jsonInput), ctx)
392+
393+
then:
394+
1 * ctx.setWafTruncated()
395+
}
396+
397+
void 'jackson with deep nesting triggers depth limit'() {
398+
setup:
399+
// Create deeply nested JSON
400+
final json = JsonOutput.toJson(
401+
(1..(ObjectIntrospection.MAX_DEPTH + 1)).inject([:], { result, i -> [("child_$i".toString()) : result] })
402+
)
403+
404+
when:
405+
convert(MAPPER.readTree(json), ctx)
406+
407+
then:
408+
// Should truncate at max depth and set truncation flag
409+
1 * ctx.setWafTruncated()
410+
}
411+
412+
void 'jackson with large arrays triggers element limit'() {
413+
setup:
414+
// Create large array
415+
final largeArray = (1..(ObjectIntrospection.MAX_ELEMENTS + 1)).toList()
416+
final json = new JsonBuilder(largeArray).toString()
417+
418+
when:
419+
convert(MAPPER.readTree(json), ctx)
420+
421+
then:
422+
// Should truncate and set truncation flag
423+
1 * ctx.setWafTruncated()
424+
}
425+
426+
void 'jackson number type variations'() {
427+
when:
428+
final result = convert(input, ctx)
429+
430+
then:
431+
result == expected
432+
433+
where:
434+
input || expected
435+
MAPPER.readTree('0') || 0
436+
MAPPER.readTree('1') || 1
437+
MAPPER.readTree('-1') || -1
438+
MAPPER.readTree('1.0') || 1.0
439+
MAPPER.readTree('1.5') || 1.5
440+
MAPPER.readTree('-1.5') || -1.5
441+
MAPPER.readTree('1e10') || 1e10
442+
MAPPER.readTree('1.23e-4') || 1.23e-4
443+
}
444+
445+
void 'jackson special string values'() {
446+
when:
447+
final result = convert(input, ctx)
448+
449+
then:
450+
result == expected
451+
452+
where:
453+
input || expected
454+
MAPPER.readTree('"\\n"') || '\n'
455+
MAPPER.readTree('"\\t"') || '\t'
456+
MAPPER.readTree('"\\r"') || '\r'
457+
MAPPER.readTree('"\\\\"') || '\\'
458+
MAPPER.readTree('"\\"quotes\\""') || '"quotes"'
459+
MAPPER.readTree('"unicode: \\u0041"') || 'unicode: A'
460+
}
321461
}

dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/controller/WebController.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package datadog.smoketest.appsec.springboot.controller;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
34
import com.squareup.okhttp.OkHttpClient;
45
import com.squareup.okhttp.Request;
56
import datadog.smoketest.appsec.springboot.service.AsyncService;
@@ -18,6 +19,7 @@
1819
import org.springframework.beans.factory.annotation.Autowired;
1920
import org.springframework.http.HttpHeaders;
2021
import org.springframework.http.HttpStatus;
22+
import org.springframework.http.MediaType;
2123
import org.springframework.http.ResponseEntity;
2224
import org.springframework.web.bind.annotation.GetMapping;
2325
import org.springframework.web.bind.annotation.PathVariable;
@@ -211,6 +213,11 @@ public ResponseEntity<String> apiSecuritySampling(@PathVariable("status_code") i
211213
return ResponseEntity.status(statusCode).body("EXECUTED");
212214
}
213215

216+
@PostMapping(value = "/api_security/jackson", consumes = MediaType.APPLICATION_JSON_VALUE)
217+
public ResponseEntity<JsonNode> apiSecurityJackson(@RequestBody final JsonNode body) {
218+
return ResponseEntity.status(200).body(body);
219+
}
220+
214221
@GetMapping("/custom-headers")
215222
public ResponseEntity<String> customHeaders() {
216223
HttpHeaders headers = new HttpHeaders();

0 commit comments

Comments
 (0)
0