Skip to content

Commit 01f08ff

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

File tree

5 files changed

+321
-1
lines changed

5 files changed

+321
-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: 144 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,18 @@ 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+
}
194+
}
195+
181196
// maps
182197
if (obj instanceof Map) {
183198
Map<Object, Object> newMap = new HashMap<>((int) Math.ceil(((Map) obj).size() / .75));
@@ -212,7 +227,6 @@ private static Object doConversion(Object obj, int depth, State state) {
212227
}
213228

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

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)