From 124e9188ab287d2c3e93df58fae927808ae1ee14 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 14 Jul 2026 13:05:59 +0200 Subject: [PATCH 1/6] Bound multipart text/plain field materialization in Jersey AppSec Jersey 2/3 AppSec instrumentation read text/plain multipart field values via the unbounded getValue() and accumulated an unlimited number of distinct field names, allowing a crafted multipart request to exhaust heap/CPU before WAF limits apply. Reuse the existing byte-capped MultipartContentDecoder and the configured file-content limits (MAX_CONTENT_BYTES, MAX_FILES_TO_INSPECT) to bound both the size of each field value and the number of distinct field names collected. --- .../jersey2/MultiPartHelper.java | 10 ++- .../test/groovy/MultiPartHelperTest.groovy | 83 ++++++++++++++++++- .../jersey3/MultiPartHelper.java | 10 ++- .../test/groovy/MultiPartHelperTest.groovy | 83 ++++++++++++++++++- 4 files changed, 172 insertions(+), 14 deletions(-) diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java index 263aca69722..6d862b9a950 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java @@ -28,10 +28,14 @@ public static void collectBodyPart( Map> bodyMap, List filenames, List filesContent) { + String name = bodyPart.getName(); if (bodyMap != null - && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType())) { - // BodyPartEntity allows re-reading the part without consuming the stream - bodyMap.computeIfAbsent(bodyPart.getName(), k -> new ArrayList<>()).add(bodyPart.getValue()); + && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType()) + && (bodyMap.containsKey(name) || bodyMap.size() < MAX_FILES_TO_INSPECT)) { + // readContent() reads the part through a byte-capped decoder (MAX_CONTENT_BYTES) instead of + // the unbounded getValue(); the distinct field-name count is capped by MAX_FILES_TO_INSPECT, + // while additional values for an already-seen field name keep accumulating. + bodyMap.computeIfAbsent(name, k -> new ArrayList<>()).add(readContent(bodyPart)); } FormDataContentDisposition cd; try { diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy index 56287e7c4f1..86877ed3366 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy @@ -19,7 +19,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'field' - bodyPart.getValue() >> 'value' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('value'.bytes) bodyPart.getFormDataContentDisposition() >> null def map = [:] @@ -59,7 +59,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'tag' - bodyPart.getValue() >>> ['a', 'b'] + bodyPart.getEntityAs(InputStream) >>> [new ByteArrayInputStream('a'.bytes), new ByteArrayInputStream('b'.bytes)] bodyPart.getFormDataContentDisposition() >> null def map = [:] @@ -71,6 +71,81 @@ class MultiPartHelperTest extends Specification { map == [tag: ['a', 'b']] } + def "text/plain field value longer than MAX_CONTENT_BYTES is truncated"() { + given: + def longValue = 'a' * (MultiPartHelper.MAX_CONTENT_BYTES + 100) + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bodyPart.getName() >> 'field' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream(longValue.bytes) + bodyPart.getFormDataContentDisposition() >> null + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map['field'][0] == 'a' * MultiPartHelper.MAX_CONTENT_BYTES + } + + def "MAX_FILES_TO_INSPECT limits number of distinct body map field names"() { + given: + def parts = (1..MultiPartHelper.MAX_FILES_TO_INSPECT + 2).collect { i -> + def bp = Mock(FormDataBodyPart) + bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bp.getName() >> "field${i}" + bp.getEntityAs(InputStream) >> new ByteArrayInputStream("value${i}".bytes) + bp.getFormDataContentDisposition() >> null + bp + } + def map = [:] + + when: + parts.each { MultiPartHelper.collectBodyPart(it, map, null, null) } + + then: + map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT + } + + def "values for an existing field name accumulate even when the field-name cap is reached"() { + given: "the map filled up to the cap with distinct field names" + def existing = (1..MultiPartHelper.MAX_FILES_TO_INSPECT).collect { i -> + def bp = Mock(FormDataBodyPart) + bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bp.getName() >> "field${i}" + bp.getEntityAs(InputStream) >> { new ByteArrayInputStream("value${i}".bytes) } + bp.getFormDataContentDisposition() >> null + bp + } + def map = [:] + existing.each { MultiPartHelper.collectBodyPart(it, map, null, null) } + + and: "a body part reusing an already-collected field name" + def repeat = Mock(FormDataBodyPart) + repeat.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + repeat.getName() >> 'field1' + repeat.getEntityAs(InputStream) >> new ByteArrayInputStream('extra'.bytes) + repeat.getFormDataContentDisposition() >> null + + and: "a body part with a brand-new field name" + def fresh = Mock(FormDataBodyPart) + fresh.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + fresh.getName() >> 'brandNew' + fresh.getEntityAs(InputStream) >> new ByteArrayInputStream('nope'.bytes) + fresh.getFormDataContentDisposition() >> null + + when: + MultiPartHelper.collectBodyPart(repeat, map, null, null) + MultiPartHelper.collectBodyPart(fresh, map, null, null) + + then: "the existing field accumulates the new value" + map['field1'] == ['value1', 'extra'] + + and: "the cap gates new field names, so the map does not grow and the new name is rejected" + map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT + !map.containsKey('brandNew') + } + // collectBodyPart — filenames def "filename is added to list when present"() { @@ -96,7 +171,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'f' - bodyPart.getValue() >> 'v' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('v'.bytes) bodyPart.getFormDataContentDisposition() >> cd expect: @@ -110,7 +185,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'file' - bodyPart.getValue() >> 'content' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('content'.bytes) bodyPart.getFormDataContentDisposition() >> cd def map = [:] def filenames = [] diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java index 0792c4a395b..a6a3b390c5c 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java @@ -28,10 +28,14 @@ public static void collectBodyPart( Map> bodyMap, List filenames, List filesContent) { + String name = bodyPart.getName(); if (bodyMap != null - && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType())) { - // BodyPartEntity allows re-reading the part without consuming the stream - bodyMap.computeIfAbsent(bodyPart.getName(), k -> new ArrayList<>()).add(bodyPart.getValue()); + && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType()) + && (bodyMap.containsKey(name) || bodyMap.size() < MAX_FILES_TO_INSPECT)) { + // readContent() reads the part through a byte-capped decoder (MAX_CONTENT_BYTES) instead of + // the unbounded getValue(); the distinct field-name count is capped by MAX_FILES_TO_INSPECT, + // while additional values for an already-seen field name keep accumulating. + bodyMap.computeIfAbsent(name, k -> new ArrayList<>()).add(readContent(bodyPart)); } FormDataContentDisposition cd; try { diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy index 23feea4cc58..b706531ad97 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy @@ -19,7 +19,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'field' - bodyPart.getValue() >> 'value' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('value'.bytes) bodyPart.getFormDataContentDisposition() >> null def map = [:] @@ -59,7 +59,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'tag' - bodyPart.getValue() >>> ['a', 'b'] + bodyPart.getEntityAs(InputStream) >>> [new ByteArrayInputStream('a'.bytes), new ByteArrayInputStream('b'.bytes)] bodyPart.getFormDataContentDisposition() >> null def map = [:] @@ -71,6 +71,81 @@ class MultiPartHelperTest extends Specification { map == [tag: ['a', 'b']] } + def "text/plain field value longer than MAX_CONTENT_BYTES is truncated"() { + given: + def longValue = 'a' * (MultiPartHelper.MAX_CONTENT_BYTES + 100) + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bodyPart.getName() >> 'field' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream(longValue.bytes) + bodyPart.getFormDataContentDisposition() >> null + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map['field'][0] == 'a' * MultiPartHelper.MAX_CONTENT_BYTES + } + + def "MAX_FILES_TO_INSPECT limits number of distinct body map field names"() { + given: + def parts = (1..MultiPartHelper.MAX_FILES_TO_INSPECT + 2).collect { i -> + def bp = Mock(FormDataBodyPart) + bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bp.getName() >> "field${i}" + bp.getEntityAs(InputStream) >> new ByteArrayInputStream("value${i}".bytes) + bp.getFormDataContentDisposition() >> null + bp + } + def map = [:] + + when: + parts.each { MultiPartHelper.collectBodyPart(it, map, null, null) } + + then: + map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT + } + + def "values for an existing field name accumulate even when the field-name cap is reached"() { + given: "the map filled up to the cap with distinct field names" + def existing = (1..MultiPartHelper.MAX_FILES_TO_INSPECT).collect { i -> + def bp = Mock(FormDataBodyPart) + bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bp.getName() >> "field${i}" + bp.getEntityAs(InputStream) >> { new ByteArrayInputStream("value${i}".bytes) } + bp.getFormDataContentDisposition() >> null + bp + } + def map = [:] + existing.each { MultiPartHelper.collectBodyPart(it, map, null, null) } + + and: "a body part reusing an already-collected field name" + def repeat = Mock(FormDataBodyPart) + repeat.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + repeat.getName() >> 'field1' + repeat.getEntityAs(InputStream) >> new ByteArrayInputStream('extra'.bytes) + repeat.getFormDataContentDisposition() >> null + + and: "a body part with a brand-new field name" + def fresh = Mock(FormDataBodyPart) + fresh.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + fresh.getName() >> 'brandNew' + fresh.getEntityAs(InputStream) >> new ByteArrayInputStream('nope'.bytes) + fresh.getFormDataContentDisposition() >> null + + when: + MultiPartHelper.collectBodyPart(repeat, map, null, null) + MultiPartHelper.collectBodyPart(fresh, map, null, null) + + then: "the existing field accumulates the new value" + map['field1'] == ['value1', 'extra'] + + and: "the cap gates new field names, so the map does not grow and the new name is rejected" + map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT + !map.containsKey('brandNew') + } + // collectBodyPart — filenames def "filename is added to list when present"() { @@ -96,7 +171,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'f' - bodyPart.getValue() >> 'v' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('v'.bytes) bodyPart.getFormDataContentDisposition() >> cd expect: @@ -110,7 +185,7 @@ class MultiPartHelperTest extends Specification { def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE bodyPart.getName() >> 'file' - bodyPart.getValue() >> 'content' + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('content'.bytes) bodyPart.getFormDataContentDisposition() >> cd def map = [:] def filenames = [] From 67a5f463b1469dfc18ae5fbcdb000da7849e0cbd Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 14 Jul 2026 13:54:02 +0200 Subject: [PATCH 2/6] fix: bound multipart body map by total value count and avoid unguarded getName() - Cap now bounds total accumulated values in the body map, not just distinct field names, closing a bypass where repeating a field name skipped the limit. - Derive the body map key from FormDataContentDisposition.getName() (a safe field accessor) instead of FormDataBodyPart.getName(), which re-parses the disposition header and can throw on malformed input. Addresses Codex review findings 1 and 3 on PR #11944. --- .../jersey2/MultiPartHelper.java | 27 ++++++--- .../test/groovy/MultiPartHelperTest.groovy | 60 ++++++++++++------- .../jersey3/MultiPartHelper.java | 27 ++++++--- .../test/groovy/MultiPartHelperTest.groovy | 60 ++++++++++++------- 4 files changed, 116 insertions(+), 58 deletions(-) diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java index 6d862b9a950..a6791302ff1 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java @@ -28,15 +28,6 @@ public static void collectBodyPart( Map> bodyMap, List filenames, List filesContent) { - String name = bodyPart.getName(); - if (bodyMap != null - && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType()) - && (bodyMap.containsKey(name) || bodyMap.size() < MAX_FILES_TO_INSPECT)) { - // readContent() reads the part through a byte-capped decoder (MAX_CONTENT_BYTES) instead of - // the unbounded getValue(); the distinct field-name count is capped by MAX_FILES_TO_INSPECT, - // while additional values for an already-seen field name keep accumulating. - bodyMap.computeIfAbsent(name, k -> new ArrayList<>()).add(readContent(bodyPart)); - } FormDataContentDisposition cd; try { cd = bodyPart.getFormDataContentDisposition(); @@ -45,6 +36,16 @@ public static void collectBodyPart( // so a single bad part does not abort processing of the remaining parts. cd = null; } + if (bodyMap != null + && cd != null + && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType()) + && totalBodyMapValues(bodyMap) < MAX_FILES_TO_INSPECT) { + // readContent() reads the part through a byte-capped decoder (MAX_CONTENT_BYTES) instead of + // the unbounded getValue(); the cap counts total accumulated values across all field names, + // not distinct keys, so repeating the same field name cannot bypass the limit. cd.getName() + // is a safe field accessor, unlike bodyPart.getName() which re-parses the disposition header. + bodyMap.computeIfAbsent(cd.getName(), k -> new ArrayList<>()).add(readContent(bodyPart)); + } // rawFilename == null → no filename attribute → form field → skip filenames and content // rawFilename == "" → filename attribute present but empty → content YES, filenames NO // rawFilename != "" → file with name → both @@ -57,6 +58,14 @@ public static void collectBodyPart( } } + private static int totalBodyMapValues(Map> bodyMap) { + int total = 0; + for (List values : bodyMap.values()) { + total += values.size(); + } + return total; + } + public static String readContent(FormDataBodyPart bodyPart) { try { // getEntityAs(InputStream.class) is backed by BodyPartEntity which supports re-reading: diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy index 86877ed3366..7af4b24b15c 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy @@ -16,11 +16,12 @@ class MultiPartHelperTest extends Specification { def "text/plain part is added to body map"() { given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'field' bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('value'.bytes) - bodyPart.getFormDataContentDisposition() >> null + bodyPart.getFormDataContentDisposition() >> cd def map = [:] when: @@ -56,11 +57,12 @@ class MultiPartHelperTest extends Specification { def "multiple values for same field are accumulated"() { given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'tag' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'tag' bodyPart.getEntityAs(InputStream) >>> [new ByteArrayInputStream('a'.bytes), new ByteArrayInputStream('b'.bytes)] - bodyPart.getFormDataContentDisposition() >> null + bodyPart.getFormDataContentDisposition() >> cd def map = [:] when: @@ -74,11 +76,12 @@ class MultiPartHelperTest extends Specification { def "text/plain field value longer than MAX_CONTENT_BYTES is truncated"() { given: def longValue = 'a' * (MultiPartHelper.MAX_CONTENT_BYTES + 100) + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'field' bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream(longValue.bytes) - bodyPart.getFormDataContentDisposition() >> null + bodyPart.getFormDataContentDisposition() >> cd def map = [:] when: @@ -91,11 +94,12 @@ class MultiPartHelperTest extends Specification { def "MAX_FILES_TO_INSPECT limits number of distinct body map field names"() { given: def parts = (1..MultiPartHelper.MAX_FILES_TO_INSPECT + 2).collect { i -> + def cd = Mock(FormDataContentDisposition) + cd.getName() >> "field${i}" def bp = Mock(FormDataBodyPart) bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bp.getName() >> "field${i}" bp.getEntityAs(InputStream) >> new ByteArrayInputStream("value${i}".bytes) - bp.getFormDataContentDisposition() >> null + bp.getFormDataContentDisposition() >> cd bp } def map = [:] @@ -107,45 +111,61 @@ class MultiPartHelperTest extends Specification { map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT } - def "values for an existing field name accumulate even when the field-name cap is reached"() { + def "MAX_FILES_TO_INSPECT limits total accumulated values, even for a repeated field name"() { given: "the map filled up to the cap with distinct field names" def existing = (1..MultiPartHelper.MAX_FILES_TO_INSPECT).collect { i -> + def cd = Mock(FormDataContentDisposition) + cd.getName() >> "field${i}" def bp = Mock(FormDataBodyPart) bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bp.getName() >> "field${i}" bp.getEntityAs(InputStream) >> { new ByteArrayInputStream("value${i}".bytes) } - bp.getFormDataContentDisposition() >> null + bp.getFormDataContentDisposition() >> cd bp } def map = [:] existing.each { MultiPartHelper.collectBodyPart(it, map, null, null) } and: "a body part reusing an already-collected field name" + def repeatCd = Mock(FormDataContentDisposition) + repeatCd.getName() >> 'field1' def repeat = Mock(FormDataBodyPart) repeat.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - repeat.getName() >> 'field1' repeat.getEntityAs(InputStream) >> new ByteArrayInputStream('extra'.bytes) - repeat.getFormDataContentDisposition() >> null + repeat.getFormDataContentDisposition() >> repeatCd and: "a body part with a brand-new field name" + def freshCd = Mock(FormDataContentDisposition) + freshCd.getName() >> 'brandNew' def fresh = Mock(FormDataBodyPart) fresh.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - fresh.getName() >> 'brandNew' fresh.getEntityAs(InputStream) >> new ByteArrayInputStream('nope'.bytes) - fresh.getFormDataContentDisposition() >> null + fresh.getFormDataContentDisposition() >> freshCd when: MultiPartHelper.collectBodyPart(repeat, map, null, null) MultiPartHelper.collectBodyPart(fresh, map, null, null) - then: "the existing field accumulates the new value" - map['field1'] == ['value1', 'extra'] - - and: "the cap gates new field names, so the map does not grow and the new name is rejected" + then: "the total value cap rejects both the repeated field's extra value and the brand-new field" + map['field1'] == ['value1'] map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT !map.containsKey('brandNew') } + def "malformed Content-Disposition on a text/plain part skips body map gracefully"() { + given: + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bodyPart.getFormDataContentDisposition() >> { throw new IllegalArgumentException("bad CD") } + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map.isEmpty() + noExceptionThrown() + } + // collectBodyPart — filenames def "filename is added to list when present"() { @@ -182,9 +202,9 @@ class MultiPartHelperTest extends Specification { given: def cd = Mock(FormDataContentDisposition) cd.getFileName() >> 'upload.txt' + cd.getName() >> 'file' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'file' bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('content'.bytes) bodyPart.getFormDataContentDisposition() >> cd def map = [:] diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java index a6a3b390c5c..3b3c5834b51 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java @@ -28,15 +28,6 @@ public static void collectBodyPart( Map> bodyMap, List filenames, List filesContent) { - String name = bodyPart.getName(); - if (bodyMap != null - && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType()) - && (bodyMap.containsKey(name) || bodyMap.size() < MAX_FILES_TO_INSPECT)) { - // readContent() reads the part through a byte-capped decoder (MAX_CONTENT_BYTES) instead of - // the unbounded getValue(); the distinct field-name count is capped by MAX_FILES_TO_INSPECT, - // while additional values for an already-seen field name keep accumulating. - bodyMap.computeIfAbsent(name, k -> new ArrayList<>()).add(readContent(bodyPart)); - } FormDataContentDisposition cd; try { cd = bodyPart.getFormDataContentDisposition(); @@ -45,6 +36,16 @@ public static void collectBodyPart( // so a single bad part does not abort processing of the remaining parts. cd = null; } + if (bodyMap != null + && cd != null + && MediaTypes.typeEqual(MediaType.TEXT_PLAIN_TYPE, bodyPart.getMediaType()) + && totalBodyMapValues(bodyMap) < MAX_FILES_TO_INSPECT) { + // readContent() reads the part through a byte-capped decoder (MAX_CONTENT_BYTES) instead of + // the unbounded getValue(); the cap counts total accumulated values across all field names, + // not distinct keys, so repeating the same field name cannot bypass the limit. cd.getName() + // is a safe field accessor, unlike bodyPart.getName() which re-parses the disposition header. + bodyMap.computeIfAbsent(cd.getName(), k -> new ArrayList<>()).add(readContent(bodyPart)); + } // rawFilename == null → no filename attribute → form field → skip filenames and content // rawFilename == "" → filename attribute present but empty → content YES, filenames NO // rawFilename != "" → file with name → both @@ -57,6 +58,14 @@ public static void collectBodyPart( } } + private static int totalBodyMapValues(Map> bodyMap) { + int total = 0; + for (List values : bodyMap.values()) { + total += values.size(); + } + return total; + } + public static String readContent(FormDataBodyPart bodyPart) { try { // getEntityAs(InputStream.class) is backed by BodyPartEntity which supports re-reading: diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy index b706531ad97..ec0a1d9c23a 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy @@ -16,11 +16,12 @@ class MultiPartHelperTest extends Specification { def "text/plain part is added to body map"() { given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'field' bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('value'.bytes) - bodyPart.getFormDataContentDisposition() >> null + bodyPart.getFormDataContentDisposition() >> cd def map = [:] when: @@ -56,11 +57,12 @@ class MultiPartHelperTest extends Specification { def "multiple values for same field are accumulated"() { given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'tag' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'tag' bodyPart.getEntityAs(InputStream) >>> [new ByteArrayInputStream('a'.bytes), new ByteArrayInputStream('b'.bytes)] - bodyPart.getFormDataContentDisposition() >> null + bodyPart.getFormDataContentDisposition() >> cd def map = [:] when: @@ -74,11 +76,12 @@ class MultiPartHelperTest extends Specification { def "text/plain field value longer than MAX_CONTENT_BYTES is truncated"() { given: def longValue = 'a' * (MultiPartHelper.MAX_CONTENT_BYTES + 100) + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'field' bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream(longValue.bytes) - bodyPart.getFormDataContentDisposition() >> null + bodyPart.getFormDataContentDisposition() >> cd def map = [:] when: @@ -91,11 +94,12 @@ class MultiPartHelperTest extends Specification { def "MAX_FILES_TO_INSPECT limits number of distinct body map field names"() { given: def parts = (1..MultiPartHelper.MAX_FILES_TO_INSPECT + 2).collect { i -> + def cd = Mock(FormDataContentDisposition) + cd.getName() >> "field${i}" def bp = Mock(FormDataBodyPart) bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bp.getName() >> "field${i}" bp.getEntityAs(InputStream) >> new ByteArrayInputStream("value${i}".bytes) - bp.getFormDataContentDisposition() >> null + bp.getFormDataContentDisposition() >> cd bp } def map = [:] @@ -107,45 +111,61 @@ class MultiPartHelperTest extends Specification { map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT } - def "values for an existing field name accumulate even when the field-name cap is reached"() { + def "MAX_FILES_TO_INSPECT limits total accumulated values, even for a repeated field name"() { given: "the map filled up to the cap with distinct field names" def existing = (1..MultiPartHelper.MAX_FILES_TO_INSPECT).collect { i -> + def cd = Mock(FormDataContentDisposition) + cd.getName() >> "field${i}" def bp = Mock(FormDataBodyPart) bp.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bp.getName() >> "field${i}" bp.getEntityAs(InputStream) >> { new ByteArrayInputStream("value${i}".bytes) } - bp.getFormDataContentDisposition() >> null + bp.getFormDataContentDisposition() >> cd bp } def map = [:] existing.each { MultiPartHelper.collectBodyPart(it, map, null, null) } and: "a body part reusing an already-collected field name" + def repeatCd = Mock(FormDataContentDisposition) + repeatCd.getName() >> 'field1' def repeat = Mock(FormDataBodyPart) repeat.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - repeat.getName() >> 'field1' repeat.getEntityAs(InputStream) >> new ByteArrayInputStream('extra'.bytes) - repeat.getFormDataContentDisposition() >> null + repeat.getFormDataContentDisposition() >> repeatCd and: "a body part with a brand-new field name" + def freshCd = Mock(FormDataContentDisposition) + freshCd.getName() >> 'brandNew' def fresh = Mock(FormDataBodyPart) fresh.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - fresh.getName() >> 'brandNew' fresh.getEntityAs(InputStream) >> new ByteArrayInputStream('nope'.bytes) - fresh.getFormDataContentDisposition() >> null + fresh.getFormDataContentDisposition() >> freshCd when: MultiPartHelper.collectBodyPart(repeat, map, null, null) MultiPartHelper.collectBodyPart(fresh, map, null, null) - then: "the existing field accumulates the new value" - map['field1'] == ['value1', 'extra'] - - and: "the cap gates new field names, so the map does not grow and the new name is rejected" + then: "the total value cap rejects both the repeated field's extra value and the brand-new field" + map['field1'] == ['value1'] map.size() == MultiPartHelper.MAX_FILES_TO_INSPECT !map.containsKey('brandNew') } + def "malformed Content-Disposition on a text/plain part skips body map gracefully"() { + given: + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bodyPart.getFormDataContentDisposition() >> { throw new IllegalArgumentException("bad CD") } + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map.isEmpty() + noExceptionThrown() + } + // collectBodyPart — filenames def "filename is added to list when present"() { @@ -182,9 +202,9 @@ class MultiPartHelperTest extends Specification { given: def cd = Mock(FormDataContentDisposition) cd.getFileName() >> 'upload.txt' + cd.getName() >> 'file' def bodyPart = Mock(FormDataBodyPart) bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE - bodyPart.getName() >> 'file' bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('content'.bytes) bodyPart.getFormDataContentDisposition() >> cd def map = [:] From e25666acf51e4efaef1e4199b21cb281ed63990c Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 14 Jul 2026 14:01:36 +0200 Subject: [PATCH 3/6] fix: default multipart content decoding to UTF-8 instead of platform charset Charset.defaultCharset() depends on the JVM/platform locale and can differ from the UTF-8 default Jersey's own getValue() used, corrupting text captured by AppSec on JVMs whose platform charset isn't UTF-8. Addresses Codex review finding 2 on PR #11944. --- .../datadog/trace/api/http/MultipartContentDecoder.java | 3 ++- .../trace/api/http/MultipartContentDecoderTest.java | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java b/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java index acfd45762fa..1c467c4c4ce 100644 --- a/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java +++ b/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java @@ -6,6 +6,7 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; /** Decodes multipart file content bytes to String using the per-part Content-Type charset. */ public final class MultipartContentDecoder { @@ -23,7 +24,7 @@ public static String readInputStream(InputStream is, int maxBytes, String conten public static String decodeBytes(byte[] buf, int length, String contentType) { Charset charset = extractCharset(contentType); - if (charset == null) charset = Charset.defaultCharset(); + if (charset == null) charset = StandardCharsets.UTF_8; try { return charset .newDecoder() diff --git a/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java b/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java index 013ac39b3c7..c97c2aff744 100644 --- a/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java +++ b/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java @@ -34,15 +34,15 @@ void decodeBytesUsesDeclaredIso88591Charset() { } @Test - void decodeBytesDefaultsToMachineDefaultWhenNoCharset() { - String text = "hello world"; + void decodeBytesDefaultsToUtf8WhenNoCharset() { + String text = "héllo wörld"; byte[] bytes = text.getBytes(StandardCharsets.UTF_8); assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain")); } @Test - void decodeBytesDefaultsToMachineDefaultWhenNullContentType() { - String text = "hello world"; + void decodeBytesDefaultsToUtf8WhenNullContentType() { + String text = "héllo wörld"; byte[] bytes = text.getBytes(StandardCharsets.UTF_8); assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, null)); } From 26f0d25820bedd479786e575a85a9fffcb27269b Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 14 Jul 2026 14:16:46 +0200 Subject: [PATCH 4/6] Revert "fix: default multipart content decoding to UTF-8 instead of platform charset" This reverts commit e25666acf51e4efaef1e4199b21cb281ed63990c. --- .../datadog/trace/api/http/MultipartContentDecoder.java | 3 +-- .../trace/api/http/MultipartContentDecoderTest.java | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java b/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java index 1c467c4c4ce..acfd45762fa 100644 --- a/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java +++ b/internal-api/src/main/java/datadog/trace/api/http/MultipartContentDecoder.java @@ -6,7 +6,6 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; /** Decodes multipart file content bytes to String using the per-part Content-Type charset. */ public final class MultipartContentDecoder { @@ -24,7 +23,7 @@ public static String readInputStream(InputStream is, int maxBytes, String conten public static String decodeBytes(byte[] buf, int length, String contentType) { Charset charset = extractCharset(contentType); - if (charset == null) charset = StandardCharsets.UTF_8; + if (charset == null) charset = Charset.defaultCharset(); try { return charset .newDecoder() diff --git a/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java b/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java index c97c2aff744..013ac39b3c7 100644 --- a/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java +++ b/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java @@ -34,15 +34,15 @@ void decodeBytesUsesDeclaredIso88591Charset() { } @Test - void decodeBytesDefaultsToUtf8WhenNoCharset() { - String text = "héllo wörld"; + void decodeBytesDefaultsToMachineDefaultWhenNoCharset() { + String text = "hello world"; byte[] bytes = text.getBytes(StandardCharsets.UTF_8); assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain")); } @Test - void decodeBytesDefaultsToUtf8WhenNullContentType() { - String text = "héllo wörld"; + void decodeBytesDefaultsToMachineDefaultWhenNullContentType() { + String text = "hello world"; byte[] bytes = text.getBytes(StandardCharsets.UTF_8); assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, null)); } From 06683b843963b5ffe2a6018496770f2a893333a5 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Tue, 14 Jul 2026 14:21:09 +0200 Subject: [PATCH 5/6] fix: scope UTF-8 default charset to Jersey, revert change to shared MultipartContentDecoder MultipartContentDecoder is shared by Tomcat, Netty, Vert.x, RESTEasy and commons-fileupload. Its Charset.defaultCharset() fallback was a deliberate, reviewed decision (Manuel, PR #11198) - changing it in e25666acf5 silently altered production behavior for all those integrations, not just Jersey. Revert the shared decoder change and instead default to UTF-8 only in Jersey's MultiPartHelper, matching Jersey's own getValue() default, by appending charset=UTF-8 to the contentType before calling the shared decoder. --- .../jersey2/MultiPartHelper.java | 14 ++++++-- .../test/groovy/MultiPartHelperTest.groovy | 35 +++++++++++++++++++ .../jersey3/MultiPartHelper.java | 14 ++++++-- .../test/groovy/MultiPartHelperTest.groovy | 35 +++++++++++++++++++ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java index a6791302ff1..8b54e887117 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java @@ -72,15 +72,23 @@ public static String readContent(FormDataBodyPart bodyPart) { // each call creates a fresh stream from the buffered MIME part data. try (InputStream is = bodyPart.getEntityAs(InputStream.class)) { if (is == null) return ""; - String contentType = - bodyPart.getMediaType() != null ? bodyPart.getMediaType().toString() : null; - return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, contentType); + return MultipartContentDecoder.readInputStream( + is, MAX_CONTENT_BYTES, contentTypeWithDefaultUtf8(bodyPart.getMediaType())); } } catch (IOException ignored) { return ""; } } + // Jersey's own getValue() decodes undeclared-charset text parts as UTF-8; match that default + // here instead of falling through to MultipartContentDecoder's platform-dependent fallback. + private static String contentTypeWithDefaultUtf8(MediaType mediaType) { + String contentType = mediaType != null ? mediaType.toString() : null; + return MultipartContentDecoder.extractCharset(contentType) == null + ? (contentType == null ? "charset=UTF-8" : contentType + "; charset=UTF-8") + : contentType; + } + public static BlockingException tryBlock(RequestContext ctx, Flow flow, String message) { Flow.Action action = flow.getAction(); if (action instanceof Flow.Action.RequestBlockingAction) { diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy index 7af4b24b15c..e87f828521c 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/test/groovy/MultiPartHelperTest.groovy @@ -91,6 +91,41 @@ class MultiPartHelperTest extends Specification { map['field'][0] == 'a' * MultiPartHelper.MAX_CONTENT_BYTES } + def "text/plain part with no declared charset decodes non-ASCII content as UTF-8"() { + given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('héllo wörld'.getBytes('UTF-8')) + bodyPart.getFormDataContentDisposition() >> cd + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map == [field: ['héllo wörld']] + } + + def "text/plain part with explicit non-UTF-8 charset decodes using that charset"() { + given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' + def mediaType = new MediaType('text', 'plain', [charset: 'ISO-8859-1']) + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> mediaType + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('café'.getBytes('ISO-8859-1')) + bodyPart.getFormDataContentDisposition() >> cd + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map == [field: ['café']] + } + def "MAX_FILES_TO_INSPECT limits number of distinct body map field names"() { given: def parts = (1..MultiPartHelper.MAX_FILES_TO_INSPECT + 2).collect { i -> diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java index 3b3c5834b51..82a8e3ed4d7 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java @@ -72,15 +72,23 @@ public static String readContent(FormDataBodyPart bodyPart) { // each call creates a fresh stream from the buffered MIME part data. try (InputStream is = bodyPart.getEntityAs(InputStream.class)) { if (is == null) return ""; - String contentType = - bodyPart.getMediaType() != null ? bodyPart.getMediaType().toString() : null; - return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, contentType); + return MultipartContentDecoder.readInputStream( + is, MAX_CONTENT_BYTES, contentTypeWithDefaultUtf8(bodyPart.getMediaType())); } } catch (IOException ignored) { return ""; } } + // Jersey's own getValue() decodes undeclared-charset text parts as UTF-8; match that default + // here instead of falling through to MultipartContentDecoder's platform-dependent fallback. + private static String contentTypeWithDefaultUtf8(MediaType mediaType) { + String contentType = mediaType != null ? mediaType.toString() : null; + return MultipartContentDecoder.extractCharset(contentType) == null + ? (contentType == null ? "charset=UTF-8" : contentType + "; charset=UTF-8") + : contentType; + } + public static BlockingException tryBlock(RequestContext ctx, Flow flow, String message) { Flow.Action action = flow.getAction(); if (action instanceof Flow.Action.RequestBlockingAction) { diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy index ec0a1d9c23a..aa2046ee79d 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/test/groovy/MultiPartHelperTest.groovy @@ -91,6 +91,41 @@ class MultiPartHelperTest extends Specification { map['field'][0] == 'a' * MultiPartHelper.MAX_CONTENT_BYTES } + def "text/plain part with no declared charset decodes non-ASCII content as UTF-8"() { + given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> MediaType.TEXT_PLAIN_TYPE + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('héllo wörld'.getBytes('UTF-8')) + bodyPart.getFormDataContentDisposition() >> cd + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map == [field: ['héllo wörld']] + } + + def "text/plain part with explicit non-UTF-8 charset decodes using that charset"() { + given: + def cd = Mock(FormDataContentDisposition) + cd.getName() >> 'field' + def mediaType = new MediaType('text', 'plain', [charset: 'ISO-8859-1']) + def bodyPart = Mock(FormDataBodyPart) + bodyPart.getMediaType() >> mediaType + bodyPart.getEntityAs(InputStream) >> new ByteArrayInputStream('café'.getBytes('ISO-8859-1')) + bodyPart.getFormDataContentDisposition() >> cd + def map = [:] + + when: + MultiPartHelper.collectBodyPart(bodyPart, map, null, null) + + then: + map == [field: ['café']] + } + def "MAX_FILES_TO_INSPECT limits number of distinct body map field names"() { given: def parts = (1..MultiPartHelper.MAX_FILES_TO_INSPECT + 2).collect { i -> From 74fdde70e9878ab03e63971c262cd63c2c2eb8de Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 15 Jul 2026 10:05:29 +0200 Subject: [PATCH 6/6] fix: limit UTF-8 charset default to the body-map text-field path readContent() served both the bodyMap (text field) and filesContent call sites, so the Jersey-local UTF-8 default leaked into file content too. Split it into readContent() (UTF-8 default, text fields) and readFileContent() (unmodified content type, parity with MultipartContentDecoder's fallback used by file content in every other multipart helper). --- .../jersey2/MultiPartHelper.java | 21 ++++++++++++++----- .../jersey3/MultiPartHelper.java | 21 ++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java index 8b54e887117..4728e0eb0f4 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-2.0/src/main/java/datadog/trace/instrumentation/jersey2/MultiPartHelper.java @@ -54,7 +54,7 @@ && totalBodyMapValues(bodyMap) < MAX_FILES_TO_INSPECT) { filenames.add(rawFilename); } if (filesContent != null && rawFilename != null && filesContent.size() < MAX_FILES_TO_INSPECT) { - filesContent.add(readContent(bodyPart)); + filesContent.add(readFileContent(bodyPart)); } } @@ -66,22 +66,33 @@ private static int totalBodyMapValues(Map> bodyMap) { return total; } + // Used for the body-map/text-field path: Jersey's own getValue() decodes undeclared-charset + // text parts as UTF-8, so this matches that default instead of MultipartContentDecoder's + // platform-dependent fallback. public static String readContent(FormDataBodyPart bodyPart) { + return read(bodyPart, contentTypeWithDefaultUtf8(bodyPart.getMediaType())); + } + + // Used for the filesContent path: keeps parity with the other multipart helpers, which all + // rely on MultipartContentDecoder's own charset fallback for undeclared-charset file content. + public static String readFileContent(FormDataBodyPart bodyPart) { + MediaType mediaType = bodyPart.getMediaType(); + return read(bodyPart, mediaType != null ? mediaType.toString() : null); + } + + private static String read(FormDataBodyPart bodyPart, String contentType) { try { // getEntityAs(InputStream.class) is backed by BodyPartEntity which supports re-reading: // each call creates a fresh stream from the buffered MIME part data. try (InputStream is = bodyPart.getEntityAs(InputStream.class)) { if (is == null) return ""; - return MultipartContentDecoder.readInputStream( - is, MAX_CONTENT_BYTES, contentTypeWithDefaultUtf8(bodyPart.getMediaType())); + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, contentType); } } catch (IOException ignored) { return ""; } } - // Jersey's own getValue() decodes undeclared-charset text parts as UTF-8; match that default - // here instead of falling through to MultipartContentDecoder's platform-dependent fallback. private static String contentTypeWithDefaultUtf8(MediaType mediaType) { String contentType = mediaType != null ? mediaType.toString() : null; return MultipartContentDecoder.extractCharset(contentType) == null diff --git a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java index 82a8e3ed4d7..9fe6ea94f3d 100644 --- a/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java +++ b/dd-java-agent/instrumentation/jersey/jersey-appsec/jersey-appsec-3.0/src/main/java/datadog/trace/instrumentation/jersey3/MultiPartHelper.java @@ -54,7 +54,7 @@ && totalBodyMapValues(bodyMap) < MAX_FILES_TO_INSPECT) { filenames.add(rawFilename); } if (filesContent != null && rawFilename != null && filesContent.size() < MAX_FILES_TO_INSPECT) { - filesContent.add(readContent(bodyPart)); + filesContent.add(readFileContent(bodyPart)); } } @@ -66,22 +66,33 @@ private static int totalBodyMapValues(Map> bodyMap) { return total; } + // Used for the body-map/text-field path: Jersey's own getValue() decodes undeclared-charset + // text parts as UTF-8, so this matches that default instead of MultipartContentDecoder's + // platform-dependent fallback. public static String readContent(FormDataBodyPart bodyPart) { + return read(bodyPart, contentTypeWithDefaultUtf8(bodyPart.getMediaType())); + } + + // Used for the filesContent path: keeps parity with the other multipart helpers, which all + // rely on MultipartContentDecoder's own charset fallback for undeclared-charset file content. + public static String readFileContent(FormDataBodyPart bodyPart) { + MediaType mediaType = bodyPart.getMediaType(); + return read(bodyPart, mediaType != null ? mediaType.toString() : null); + } + + private static String read(FormDataBodyPart bodyPart, String contentType) { try { // getEntityAs(InputStream.class) is backed by BodyPartEntity which supports re-reading: // each call creates a fresh stream from the buffered MIME part data. try (InputStream is = bodyPart.getEntityAs(InputStream.class)) { if (is == null) return ""; - return MultipartContentDecoder.readInputStream( - is, MAX_CONTENT_BYTES, contentTypeWithDefaultUtf8(bodyPart.getMediaType())); + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, contentType); } } catch (IOException ignored) { return ""; } } - // Jersey's own getValue() decodes undeclared-charset text parts as UTF-8; match that default - // here instead of falling through to MultipartContentDecoder's platform-dependent fallback. private static String contentTypeWithDefaultUtf8(MediaType mediaType) { String contentType = mediaType != null ? mediaType.toString() : null; return MultipartContentDecoder.extractCharset(contentType) == null