Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9b1e4df
refactor(appsec): extract MultipartContentDecoder to internal-api for…
jandro996 Apr 27, 2026
e051d4b
test(appsec): add missing corner cases to MultipartContentDecoderTest
jandro996 Apr 27, 2026
900984d
fix(appsec): use machine default charset as fallback in MultipartCont…
jandro996 Apr 28, 2026
5f8e0db
test(appsec): migrate MultipartContentDecoderTest from Groovy/Spock t…
jandro996 Apr 28, 2026
823630a
feat(appsec): extend files_content WAF address to Tomcat and Netty mu…
jandro996 Apr 24, 2026
a604480
fix(appsec): address techdebt in Tomcat/Netty files_content instrumen…
jandro996 Apr 24, 2026
ac07eef
fix(appsec): further cleanup of Tomcat/Netty files_content instrument…
jandro996 Apr 24, 2026
3f003e0
fix(appsec): align blocking paths with canonical pattern in Tomcat/Ne…
jandro996 Apr 24, 2026
1357493
fix(appsec): skip file content I/O in Netty when requestFilesContent …
jandro996 Apr 24, 2026
108d3e4
fix(appsec): align Netty filenames blocking and merge CommonsFileUplo…
jandro996 Apr 24, 2026
9bef685
refactor(appsec): remove dead readContents() from FileItemContentReader
jandro996 Apr 24, 2026
d648b6e
fix(appsec): lazy-init filesContent in Netty and align CommonsFileUpl…
jandro996 Apr 24, 2026
a653441
fix(appsec): skip file content I/O in Tomcat when requestFilesContent…
jandro996 Apr 24, 2026
44eddcb
fix(appsec): move BlockingException inside brf guard in all remaining…
jandro996 Apr 24, 2026
9b52458
refactor(appsec): cache reflection and use try-with-resources in Para…
jandro996 Apr 24, 2026
dbbc572
test(appsec): add Netty integration tests for multipart filenames and…
jandro996 Apr 24, 2026
6d847f6
refactor(appsec): extract Netty file content reading to NettyFileUplo…
jandro996 Apr 24, 2026
e811154
test(appsec): add integration tests for file content size and count l…
jandro996 Apr 24, 2026
59fe033
test(appsec): increase Netty test server aggregator limit to support …
jandro996 Apr 24, 2026
95573bb
fix(appsec): do not gate Netty file content callback on requestBodyPr…
jandro996 Apr 24, 2026
48aeb71
style: apply spotless formatting to HttpServerTest
jandro996 Apr 24, 2026
73ffb6e
fix(appsec): remove effectivelyBlocked() calls in Netty filenames and…
jandro996 Apr 24, 2026
3355a15
fix(appsec): set t before effectivelyBlocked() in Tomcat filenames an…
jandro996 Apr 24, 2026
37a5915
perf(appsec): skip attribute I/O in Netty multipart when body callbac…
jandro996 Apr 24, 2026
d1573f8
refactor(appsec): unify file content limits via Config for DD_APPSEC_…
jandro996 Apr 27, 2026
0d0e2f0
chore: register DD_APPSEC_MAX_FILE_CONTENT_BYTES/COUNT in supported-c…
jandro996 Apr 27, 2026
3c9709a
test(appsec): use Config values instead of hardcoded literals in Para…
jandro996 Apr 27, 2026
abe3cbc
fix(appsec): move MAX_FILES_TO_INSPECT out of advice inner class to f…
jandro996 Apr 27, 2026
2bf3304
style: apply spotless formatting to HttpPostRequestDecoderInstrumenta…
jandro996 Apr 27, 2026
b4d0e6e
fix(appsec): include filenames callback in Netty multipart early-retu…
jandro996 Apr 27, 2026
92d23d9
fix(appsec): use per-part charset for files_content in Tomcat and Netty
jandro996 Apr 28, 2026
a6d638b
refactor(appsec): extract shared InputStream read loop into Multipart…
jandro996 Apr 28, 2026
314867e
test(appsec): add charset-aware decoding tests for readInputStream, N…
jandro996 Apr 28, 2026
bb9cc20
test(appsec): add getContentType() to Tomcat test part doubles to fix…
jandro996 Apr 28, 2026
a0fb59e
perf(appsec): skip filenames list allocation in CommonsFileUpload whe…
jandro996 Apr 28, 2026
fe101c7
refactor(appsec): extract Netty multipart data collection to NettyMul…
jandro996 Apr 28, 2026
6b65fc0
test(appsec): declare ISO-8859-1 content type on RawBytesPart to matc…
jandro996 Apr 28, 2026
a30ea10
refactor(appsec): extract tryBlock helper to NettyMultipartHelper
jandro996 Apr 28, 2026
3d73c77
test(appsec): add unit tests for NettyMultipartHelper.tryBlock
jandro996 Apr 28, 2026
8e41973
Add server.request.body.filenames AppSec address for Jersey and RESTE…
jandro996 Apr 28, 2026
be17ebc
feat(appsec): extend files_content WAF address to Jersey and RESTEasy
jandro996 Apr 29, 2026
96c2410
test(appsec): add unit tests for Jersey and RESTEasy multipart helpers
jandro996 Apr 29, 2026
499a7bf
fix(appsec): fix tryBlock ordering and close InputStream in Jersey/RE…
jandro996 Apr 29, 2026
85ac118
fix(appsec): ensure filesContent callback always fires in Jersey/REST…
jandro996 Apr 29, 2026
2a647ca
test(appsec): add unit tests for collectFilesContent in RESTEasy mult…
jandro996 Apr 29, 2026
58954d0
fix(appsec): remove unnecessary java.io.ByteArrayInputStream import f…
jandro996 Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
ss.registerCallback(events.requestBodyDone(), callbacks.requestBodyEndCb)
ss.registerCallback(events.requestBodyProcessed(), callbacks.requestBodyObjectCb)
ss.registerCallback(events.requestFilesFilenames(), callbacks.requestFilesFilenamesCb)
ss.registerCallback(events.requestFilesContent(), callbacks.requestFilesContentCb)
ss.registerCallback(events.responseBody(), callbacks.responseBodyObjectCb)
ss.registerCallback(events.responseStarted(), callbacks.responseStartedCb)
ss.registerCallback(events.responseHeader(), callbacks.responseHeaderCb)
Expand Down Expand Up @@ -372,6 +373,10 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
false
}

boolean testBodyFilesContent() {
false
}

boolean testBodyJson() {
false
}
Expand Down Expand Up @@ -1652,6 +1657,82 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
response.close()
}

def 'test instrumentation gateway file upload content'() {
setup:
assumeTrue(testBodyFilesContent())
RequestBody fileBody = RequestBody.create(MediaType.parse('application/octet-stream'), 'file content')
def body = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart('file', 'test.bin', fileBody)
.build()
def httpRequest = request(BODY_MULTIPART, 'POST', body).build()
def response = client.newCall(httpRequest).execute()

when:
TEST_WRITER.waitForTraces(1)

then:
TEST_WRITER.get(0).any {
it.getTag('request.body.files_content') == '[file content]'
}

cleanup:
response.close()
}

def 'test instrumentation gateway file upload content truncated at max size'() {
setup:
assumeTrue(testBodyFilesContent())
def maxContentBytes = Config.get().getAppSecMaxFileContentBytes()
def body = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart('file', 'large.bin',
RequestBody.create(MediaType.parse('application/octet-stream'), 'X' * (maxContentBytes + 500)))
.build()
def httpRequest = request(BODY_MULTIPART, 'POST', body).build()
def response = client.newCall(httpRequest).execute()

when:
TEST_WRITER.waitForTraces(1)

then:
TEST_WRITER.get(0).any {
span ->
span.getTag('request.body.files_content') == '[' + 'X' * maxContentBytes + ']'
}

cleanup:
response.close()
}

def 'test instrumentation gateway file upload content max files limit'() {
setup:
assumeTrue(testBodyFilesContent())
def maxFilesToInspect = Config.get().getAppSecMaxFileContentCount()
def bodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM)
(1..maxFilesToInspect + 1).each {
i ->
bodyBuilder.addFormDataPart("file$i", "file${i}.bin",
RequestBody.create(MediaType.parse('application/octet-stream'), "content_of_file_$i"))
}
def httpRequest = request(BODY_MULTIPART, 'POST', bodyBuilder.build()).build()
def response = client.newCall(httpRequest).execute()

when:
TEST_WRITER.waitForTraces(1)

then:
TEST_WRITER.get(0).any {
span ->
def tag = span.getTag('request.body.files_content') as String
tag?.contains("content_of_file_$maxFilesToInspect") &&
!tag.contains("content_of_file_${maxFilesToInspect + 1}")
}

cleanup:
response.close()
}

def 'test instrumentation gateway json request body'() {
setup:
assumeTrue(testBodyJson())
Expand Down Expand Up @@ -2589,6 +2670,7 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
boolean responseBodyTag
Object responseBody
List<String> uploadedFilenames
List<String> uploadedFilesContent
}

static final String stringOrEmpty(String string) {
Expand Down Expand Up @@ -2765,6 +2847,15 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
Flow.ResultFlow.empty()
} as BiFunction<RequestContext, List<String>, Flow<Void>>)

final BiFunction<RequestContext, List<String>, Flow<Void>> requestFilesContentCb =
({
RequestContext rqCtxt, List<String> contents ->
rqCtxt.traceSegment.setTagTop('request.body.files_content', contents as String)
Context context = rqCtxt.getData(RequestContextSlot.APPSEC)
context.uploadedFilesContent = contents
Flow.ResultFlow.empty()
} as BiFunction<RequestContext, List<String>, Flow<Void>>)

final BiFunction<RequestContext, Object, Flow<Void>> responseBodyObjectCb =
({
RequestContext rqCtxt, Object obj ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,23 @@ static void after(
return;
}

List<String> filenames = new ArrayList<>();
List<String> filenames = filenamesCallback != null ? new ArrayList<>() : null;
List<String> filesContent = contentCallback != null ? new ArrayList<>() : null;
for (FileItem fileItem : fileItems) {
if (fileItem.isFormField()) {
continue;
}
String name = fileItem.getName();
if (name != null && !name.isEmpty()) {
if (filenames != null && name != null && !name.isEmpty()) {
filenames.add(name);
}
}
if (filenames.isEmpty() && contentCallback == null) {
return;
if (filesContent != null
&& filesContent.size() < FileItemContentReader.MAX_FILES_TO_INSPECT) {
filesContent.add(FileItemContentReader.readContent(fileItem));
}
}

// Fire filenames event
if (filenamesCallback != null && !filenames.isEmpty()) {
if (filenames != null && !filenames.isEmpty()) {
Flow<Void> flow = filenamesCallback.apply(reqCtx, filenames);
Flow.Action action = flow.getAction();
if (action instanceof Flow.Action.RequestBlockingAction) {
Expand All @@ -98,28 +99,21 @@ static void after(
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
t = new BlockingException("Blocked request (multipart file upload)");
reqCtx.getTraceSegment().effectivelyBlocked();
return;
}
}
}

// Fire content event only if not blocked
if (contentCallback == null) {
return;
}
List<String> filesContent = FileItemContentReader.readContents(fileItems);
if (filesContent.isEmpty()) {
return;
}
Flow<Void> contentFlow = contentCallback.apply(reqCtx, filesContent);
Flow.Action contentAction = contentFlow.getAction();
if (contentAction instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) contentAction;
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
if (brf != null) {
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
t = new BlockingException("Blocked request (multipart file upload content)");
reqCtx.getTraceSegment().effectivelyBlocked();
if (t == null && filesContent != null && !filesContent.isEmpty()) {
Flow<Void> contentFlow = contentCallback.apply(reqCtx, filesContent);
Flow.Action contentAction = contentFlow.getAction();
if (contentAction instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) contentAction;
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
if (brf != null) {
brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
t = new BlockingException("Blocked request (multipart file upload content)");
reqCtx.getTraceSegment().effectivelyBlocked();
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,41 +1,20 @@
package datadog.trace.instrumentation.commons.fileupload;

import datadog.trace.api.Config;
import datadog.trace.api.http.MultipartContentDecoder;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.fileupload.FileItem;

/** Reads uploaded file content for WAF inspection. */
public final class FileItemContentReader {
public static final int MAX_CONTENT_BYTES = 4096;
public static final int MAX_FILES_TO_INSPECT = 25;

public static List<String> readContents(List<FileItem> fileItems) {
List<String> result = new ArrayList<>();
for (FileItem fileItem : fileItems) {
if (result.size() >= MAX_FILES_TO_INSPECT) {
break;
}
if (fileItem.isFormField()) {
continue;
}
result.add(readContent(fileItem));
}
return result;
}
public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes();
public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount();

public static String readContent(FileItem fileItem) {
try (InputStream is = fileItem.getInputStream()) {
byte[] buf = new byte[MAX_CONTENT_BYTES];
int total = 0;
int n;
while (total < MAX_CONTENT_BYTES
&& (n = is.read(buf, total, MAX_CONTENT_BYTES - total)) != -1) {
total += n;
}
return MultipartContentDecoder.decodeBytes(buf, total, fileItem.getContentType());
return MultipartContentDecoder.readInputStream(
is, MAX_CONTENT_BYTES, fileItem.getContentType());
} catch (IOException ignored) {
return "";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,63 +48,6 @@ class FileItemContentReaderTest extends Specification {
FileItemContentReader.readContent(item) == text
}

void 'readContents returns content for each non-form file with a name'() {
given:
def items = [fileItem('content-a', 'file-a.txt'), fileItem('content-b', 'file-b.txt'),]

when:
def result = FileItemContentReader.readContents(items)

then:
result == ['content-a', 'content-b']
}

void 'readContents skips form fields'() {
given:
FileItem formField = Stub(FileItem)
formField.isFormField() >> true
def items = [formField, fileItem('content', 'real.txt')]

when:
def result = FileItemContentReader.readContents(items)

then:
result == ['content']
}

void 'readContents includes file parts with empty or null name'() {
given:
def items = [
fileItem('content-no-name', null),
fileItem('content-empty-name', ''),
fileItem('content-named', 'named.txt'),
]

when:
def result = FileItemContentReader.readContents(items)

then:
result == ['content-no-name', 'content-empty-name', 'content-named']
}

void 'readContents stops after MAX_FILES_TO_INSPECT files'() {
given:
def items = (1..FileItemContentReader.MAX_FILES_TO_INSPECT + 1).collect {
fileItem("content-${it}", "file-${it}.txt")
}

when:
def result = FileItemContentReader.readContents(items)

then:
result.size() == FileItemContentReader.MAX_FILES_TO_INSPECT
}

void 'readContents returns empty list for empty input'() {
expect:
FileItemContentReader.readContents([]) == []
}

private FileItem fileItem(String content) {
fileItem(content, 'file.txt')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ class GrizzlyTest extends HttpServerTest<HttpServer> {
true
}

@Override
boolean testBodyFilenames() {
true
}

@Override
boolean testBodyJson() {
true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ class Jersey2JettyTest extends HttpServerTest<JettyServer> {
true
}

@Override
boolean testBodyFilenames() {
true
}

@Override
boolean testBodyJson() {
true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ class Jersey3JettyTest extends HttpServerTest<JettyServer> {
true
}

@Override
boolean testBodyFilenames() {
true
}

@Override
boolean testBodyJson() {
true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,16 @@ muzzle {

apply from: "$rootDir/gradle/java.gradle"

configurations.configureEach {
resolutionStrategy.deactivateDependencyLocking()
}

dependencies {
compileOnly group: 'org.glassfish.jersey.core', name: 'jersey-common', version: '2.0'
compileOnly group: 'org.glassfish.jersey.core', name: 'jersey-server', version: '2.0'
compileOnly group: 'org.glassfish.jersey.media', name: 'jersey-media-multipart', version: '2.0'

testImplementation group: 'org.glassfish.jersey.media', name: 'jersey-media-multipart', version: '2.18'
}

// tested in grizzly-http-2.3.20
Loading
Loading