Skip to content

Commit d1dbd04

Browse files
Merge branch 'sezen.leblay/upgrade-libddwaf-java-1.23' into sezen.leblay/APPSEC-57270-default-regex-change
2 parents bf869d8 + 60d5eee commit d1dbd04

File tree

8 files changed

+383
-4
lines changed

8 files changed

+383
-4
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+
}
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: 157 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,152 @@ 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+
final result = convert(MAPPER.readTree(jsonInput), ctx)
392+
393+
then:
394+
1 * ctx.setWafTruncated()
395+
1 * wafMetricCollector.wafInputTruncated(true, false, false)
396+
result["long"].length() <= ObjectIntrospection.MAX_STRING_LENGTH
397+
}
398+
399+
void 'jackson with deep nesting triggers depth limit'() {
400+
setup:
401+
// Create deeply nested JSON
402+
final json = JsonOutput.toJson(
403+
(1..(ObjectIntrospection.MAX_DEPTH + 1)).inject([:], { result, i -> [("child_$i".toString()) : result] })
404+
)
405+
406+
when:
407+
final result = convert(MAPPER.readTree(json), ctx)
408+
409+
then:
410+
// Should truncate at max depth and set truncation flag
411+
1 * ctx.setWafTruncated()
412+
1 * wafMetricCollector.wafInputTruncated(false, false, true)
413+
countNesting(result as Map, 0) <= ObjectIntrospection.MAX_DEPTH
414+
}
415+
416+
void 'jackson with large arrays triggers element limit'() {
417+
setup:
418+
// Create large array
419+
final largeArray = (1..(ObjectIntrospection.MAX_ELEMENTS + 1)).toList()
420+
final json = new JsonBuilder(largeArray).toString()
421+
422+
when:
423+
final result = convert(MAPPER.readTree(json), ctx) as List
424+
425+
then:
426+
// Should truncate and set truncation flag
427+
1 * ctx.setWafTruncated()
428+
1 * wafMetricCollector.wafInputTruncated(false, true, false)
429+
result.size() <= ObjectIntrospection.MAX_ELEMENTS
430+
}
431+
432+
void 'jackson number type variations'() {
433+
when:
434+
final result = convert(input, ctx)
435+
436+
then:
437+
result == expected
438+
439+
where:
440+
input || expected
441+
MAPPER.readTree('0') || 0
442+
MAPPER.readTree('1') || 1
443+
MAPPER.readTree('-1') || -1
444+
MAPPER.readTree('1.0') || 1.0
445+
MAPPER.readTree('1.5') || 1.5
446+
MAPPER.readTree('-1.5') || -1.5
447+
MAPPER.readTree('1e10') || 1e10
448+
MAPPER.readTree('1.23e-4') || 1.23e-4
449+
}
450+
451+
void 'jackson special string values'() {
452+
when:
453+
final result = convert(input, ctx)
454+
455+
then:
456+
result == expected
457+
458+
where:
459+
input || expected
460+
MAPPER.readTree('"\\n"') || '\n'
461+
MAPPER.readTree('"\\t"') || '\t'
462+
MAPPER.readTree('"\\r"') || '\r'
463+
MAPPER.readTree('"\\\\"') || '\\'
464+
MAPPER.readTree('"\\"quotes\\""') || '"quotes"'
465+
MAPPER.readTree('"unicode: \\u0041"') || 'unicode: A'
466+
}
467+
468+
private static int countNesting(final Map<String, Object>object, final int levels) {
469+
if (object.isEmpty()) {
470+
return levels
471+
}
472+
final child = object.values().first()
473+
if (child == null) {
474+
return levels
475+
}
476+
return countNesting(object.values().first() as Map, levels + 1)
477+
}
321478
}

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)