Skip to content

Commit 4738eed

Browse files
committed
refactor(appsec): extract MultipartContentDecoder to internal-api for reuse across integrations
1 parent 733393c commit 4738eed

4 files changed

Lines changed: 132 additions & 64 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
package datadog.trace.instrumentation.commons.fileupload;
22

3+
import datadog.trace.api.http.MultipartContentDecoder;
34
import java.io.IOException;
45
import java.io.InputStream;
5-
import java.nio.ByteBuffer;
6-
import java.nio.charset.CharacterCodingException;
7-
import java.nio.charset.Charset;
8-
import java.nio.charset.CodingErrorAction;
9-
import java.nio.charset.StandardCharsets;
106
import java.util.ArrayList;
117
import java.util.List;
12-
import java.util.Locale;
138
import org.apache.commons.fileupload.FileItem;
149

1510
/** Reads uploaded file content for WAF inspection. */
@@ -40,38 +35,11 @@ public static String readContent(FileItem fileItem) {
4035
&& (n = is.read(buf, total, MAX_CONTENT_BYTES - total)) != -1) {
4136
total += n;
4237
}
43-
return decodeBytes(buf, total, fileItem.getContentType());
38+
return MultipartContentDecoder.decodeBytes(buf, total, fileItem.getContentType());
4439
} catch (IOException ignored) {
4540
return "";
4641
}
4742
}
4843

49-
static String decodeBytes(byte[] buf, int length, String contentType) {
50-
Charset charset = extractCharset(contentType);
51-
if (charset == null) charset = StandardCharsets.UTF_8;
52-
try {
53-
return charset
54-
.newDecoder()
55-
.onMalformedInput(CodingErrorAction.REPORT)
56-
.onUnmappableCharacter(CodingErrorAction.REPORT)
57-
.decode(ByteBuffer.wrap(buf, 0, length))
58-
.toString();
59-
} catch (CharacterCodingException e) {
60-
return new String(buf, 0, length, StandardCharsets.ISO_8859_1);
61-
}
62-
}
63-
64-
static Charset extractCharset(String contentType) {
65-
if (contentType == null) return null;
66-
int idx = contentType.toLowerCase(Locale.ROOT).indexOf("charset=");
67-
if (idx < 0) return null;
68-
String name = contentType.substring(idx + 8).split("[;, ]")[0].trim();
69-
try {
70-
return Charset.forName(name);
71-
} catch (IllegalArgumentException e) {
72-
return null;
73-
}
74-
}
75-
7644
private FileItemContentReader() {}
7745
}

dd-java-agent/instrumentation/commons-fileupload-1.5/src/test/groovy/FileItemContentReaderTest.groovy

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -133,36 +133,6 @@ class FileItemContentReaderTest extends Specification {
133133
FileItemContentReader.readContents([]) == []
134134
}
135135

136-
void 'extractCharset returns null for null contentType'() {
137-
expect:
138-
FileItemContentReader.extractCharset(null) == null
139-
}
140-
141-
void 'extractCharset returns null for contentType without charset'() {
142-
expect:
143-
FileItemContentReader.extractCharset('text/plain') == null
144-
FileItemContentReader.extractCharset('image/jpeg') == null
145-
FileItemContentReader.extractCharset('application/octet-stream') == null
146-
}
147-
148-
void 'extractCharset returns null for invalid charset name'() {
149-
expect:
150-
FileItemContentReader.extractCharset('text/plain; charset=NOTACHARSET') == null
151-
}
152-
153-
void 'extractCharset extracts charset case-insensitively'() {
154-
expect:
155-
FileItemContentReader.extractCharset('text/plain; CHARSET=UTF-8').name() == 'UTF-8'
156-
FileItemContentReader.extractCharset('text/plain; Charset=UTF-8').name() == 'UTF-8'
157-
FileItemContentReader.extractCharset('text/plain; charset=utf-8').name() == 'UTF-8'
158-
}
159-
160-
void 'extractCharset extracts charset from standard Content-Type'() {
161-
expect:
162-
FileItemContentReader.extractCharset('text/plain; charset=UTF-8').name() == 'UTF-8'
163-
FileItemContentReader.extractCharset('text/xml; charset=ISO-8859-1').name() == 'ISO-8859-1'
164-
}
165-
166136
private FileItem fileItem(String content) {
167137
fileItem(content, 'file.txt')
168138
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package datadog.trace.api.http;
2+
3+
import java.nio.ByteBuffer;
4+
import java.nio.charset.CharacterCodingException;
5+
import java.nio.charset.Charset;
6+
import java.nio.charset.CodingErrorAction;
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.Locale;
9+
10+
/** Decodes multipart file content bytes to String using the per-part Content-Type charset. */
11+
public final class MultipartContentDecoder {
12+
13+
public static String decodeBytes(byte[] buf, int length, String contentType) {
14+
Charset charset = extractCharset(contentType);
15+
if (charset == null) charset = StandardCharsets.UTF_8;
16+
try {
17+
return charset
18+
.newDecoder()
19+
.onMalformedInput(CodingErrorAction.REPORT)
20+
.onUnmappableCharacter(CodingErrorAction.REPORT)
21+
.decode(ByteBuffer.wrap(buf, 0, length))
22+
.toString();
23+
} catch (CharacterCodingException e) {
24+
return new String(buf, 0, length, StandardCharsets.ISO_8859_1);
25+
}
26+
}
27+
28+
public static Charset extractCharset(String contentType) {
29+
if (contentType == null) return null;
30+
int idx = contentType.toLowerCase(Locale.ROOT).indexOf("charset=");
31+
if (idx < 0) return null;
32+
String name = contentType.substring(idx + 8).split("[;, ]")[0].trim();
33+
try {
34+
return Charset.forName(name);
35+
} catch (IllegalArgumentException e) {
36+
return null;
37+
}
38+
}
39+
40+
private MultipartContentDecoder() {}
41+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package datadog.trace.api.http
2+
3+
import spock.lang.Specification
4+
5+
class MultipartContentDecoderTest extends Specification {
6+
7+
void 'decodeBytes uses declared UTF-8 charset'() {
8+
given:
9+
def text = 'héllo wörld'
10+
byte[] bytes = text.getBytes('UTF-8')
11+
12+
expect:
13+
MultipartContentDecoder.decodeBytes(bytes, bytes.length, 'text/plain; charset=UTF-8') == text
14+
}
15+
16+
void 'decodeBytes falls back to UTF-8 when Content-Type has no charset'() {
17+
given:
18+
def text = 'hello world'
19+
byte[] bytes = text.getBytes('UTF-8')
20+
21+
expect:
22+
MultipartContentDecoder.decodeBytes(bytes, bytes.length, 'text/plain') == text
23+
}
24+
25+
void 'decodeBytes falls back to UTF-8 when Content-Type is null'() {
26+
given:
27+
def text = 'hello world'
28+
byte[] bytes = text.getBytes('UTF-8')
29+
30+
expect:
31+
MultipartContentDecoder.decodeBytes(bytes, bytes.length, null) == text
32+
}
33+
34+
void 'decodeBytes falls back to ISO-8859-1 when bytes are invalid for declared charset'() {
35+
given:
36+
// 0xE9 is 'é' in ISO-8859-1 but an invalid lone UTF-8 byte
37+
byte[] bytes = 'café'.getBytes('ISO-8859-1')
38+
39+
expect:
40+
MultipartContentDecoder.decodeBytes(bytes, bytes.length, null) == 'café'
41+
}
42+
43+
void 'decodeBytes uses declared ISO-8859-1 charset'() {
44+
given:
45+
def text = 'café'
46+
byte[] bytes = text.getBytes('ISO-8859-1')
47+
48+
expect:
49+
MultipartContentDecoder.decodeBytes(bytes, bytes.length, 'text/plain; charset=ISO-8859-1') == text
50+
}
51+
52+
void 'decodeBytes respects length parameter'() {
53+
given:
54+
byte[] bytes = 'hello world'.getBytes('UTF-8')
55+
56+
expect:
57+
MultipartContentDecoder.decodeBytes(bytes, 5, null) == 'hello'
58+
}
59+
60+
void 'extractCharset returns null for null contentType'() {
61+
expect:
62+
MultipartContentDecoder.extractCharset(null) == null
63+
}
64+
65+
void 'extractCharset returns null for contentType without charset'() {
66+
expect:
67+
MultipartContentDecoder.extractCharset('text/plain') == null
68+
MultipartContentDecoder.extractCharset('image/jpeg') == null
69+
MultipartContentDecoder.extractCharset('application/octet-stream') == null
70+
}
71+
72+
void 'extractCharset returns null for invalid charset name'() {
73+
expect:
74+
MultipartContentDecoder.extractCharset('text/plain; charset=NOTACHARSET') == null
75+
}
76+
77+
void 'extractCharset extracts charset case-insensitively'() {
78+
expect:
79+
MultipartContentDecoder.extractCharset('text/plain; CHARSET=UTF-8').name() == 'UTF-8'
80+
MultipartContentDecoder.extractCharset('text/plain; Charset=UTF-8').name() == 'UTF-8'
81+
MultipartContentDecoder.extractCharset('text/plain; charset=utf-8').name() == 'UTF-8'
82+
}
83+
84+
void 'extractCharset extracts charset from standard Content-Type'() {
85+
expect:
86+
MultipartContentDecoder.extractCharset('text/plain; charset=UTF-8').name() == 'UTF-8'
87+
MultipartContentDecoder.extractCharset('text/xml; charset=ISO-8859-1').name() == 'ISO-8859-1'
88+
}
89+
}

0 commit comments

Comments
 (0)