diff --git a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy index 3cf73374fb2..0a0f2fd9e88 100644 --- a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy +++ b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy @@ -1785,8 +1785,8 @@ abstract class HttpServerTest extends WithHttpServer { 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}") + // Exactly maxFilesToInspect files inspected; which file is excluded depends on iteration order + tag != null && tag.count('content_of_file_') == maxFilesToInspect } cleanup: diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java index 388818f8704..ffa5b1e7ac5 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/MultipartHelper.java @@ -3,26 +3,36 @@ import static datadog.trace.api.gateway.Events.EVENTS; import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.api.Config; import datadog.trace.api.gateway.BlockResponseFunction; import datadog.trace.api.gateway.CallbackProvider; import datadog.trace.api.gateway.Flow; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.api.http.MultipartContentDecoder; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import jakarta.servlet.http.Part; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.function.BiFunction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class MultipartHelper { + public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes(); + public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount(); + + private static final Logger log = LoggerFactory.getLogger(MultipartHelper.class); + private MultipartHelper() {} /** * Extracts non-null, non-empty filenames from a collection of multipart {@link Part}s using - * {@link Part#getSubmittedFileName()} (Servlet 5.0+, Jetty 11.x). + * {@link Part#getSubmittedFileName()} (Servlet 3.1+, Jetty 11.0.x). * * @return list of filenames; never {@code null}, may be empty */ @@ -39,11 +49,80 @@ public static List extractFilenames(Collection parts) { } } catch (Exception ignored) { // malformed or inaccessible part — skip and continue with remaining parts + log.debug("extractFilenames: skipping malformed part", ignored); } } return filenames; } + /** + * Extracts file content from a collection of multipart {@link Part}s. Form fields (those with a + * {@code null} submitted filename) are skipped. Reads up to {@link #MAX_CONTENT_BYTES} bytes per + * part, up to {@link #MAX_FILES_TO_INSPECT} parts total. + * + * @return list of decoded content strings; never {@code null}, may be empty + */ + public static List extractContents(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List contents = new ArrayList<>(Math.min(parts.size(), MAX_FILES_TO_INSPECT)); + for (Part part : parts) { + if (contents.size() >= MAX_FILES_TO_INSPECT) { + break; + } + try { + if (part.getSubmittedFileName() == null) { + continue; // form field — skip + } + contents.add(readFileContent(part)); + } catch (Exception ignored) { + log.debug("extractContents: skipping malformed part", ignored); + } + } + return contents; + } + + private static String readFileContent(Part part) { + try (InputStream is = part.getInputStream()) { + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, part.getContentType()); + } catch (Exception e) { + log.debug("readFileContent: stream read failed", e); + return ""; + } + } + + /** + * Fires the {@code requestFilesContent} IG event and returns a {@link BlockingException} if the + * WAF requests blocking, or {@code null} otherwise. + */ + public static BlockingException fireFilesContentEvent( + Collection parts, RequestContext reqCtx) { + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesContent()); + if (callback == null) { + return null; + } + List contents = extractContents(parts); + if (contents.isEmpty()) { + return null; + } + Flow flow = callback.apply(reqCtx, contents); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + if (brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba)) { + reqCtx.getTraceSegment().effectivelyBlocked(); + return new BlockingException("Blocked request (multipart file content)"); + } + } + } + return null; + } + /** * Fires the {@code requestFilesFilenames} IG event and returns a {@link BlockingException} if the * WAF requests blocking, or {@code null} otherwise. diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java index 3e32765c578..7b4374cfea9 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/main/java/datadog/trace/instrumentation/jetty11/RequestExtractContentParametersInstrumentation.java @@ -162,6 +162,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } @@ -188,6 +191,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/java/datadog/trace/instrumentation/jetty11/MultipartHelperTest.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/java/datadog/trace/instrumentation/jetty11/MultipartHelperTest.java index 6a929e9debd..6d20a79bbb4 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/java/datadog/trace/instrumentation/jetty11/MultipartHelperTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-11.0/src/test/java/datadog/trace/instrumentation/jetty11/MultipartHelperTest.java @@ -8,6 +8,11 @@ import static org.mockito.Mockito.when; import jakarta.servlet.http.Part; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; @@ -64,4 +69,79 @@ private Part part(String submittedFileName) { when(p.getSubmittedFileName()).thenReturn(submittedFileName); return p; } + + // ── extractContents ───────────────────────────────────────────────────────── + + @Test + void extractContentsReturnsEmptyListForNull() { + assertEquals(emptyList(), MultipartHelper.extractContents(null)); + } + + @Test + void extractContentsReturnsEmptyListForEmpty() { + assertEquals(emptyList(), MultipartHelper.extractContents(emptyList())); + } + + @Test + void extractContentsSkipsFormFieldParts() { + List parts = asList(part(null), part(null)); + assertEquals(emptyList(), MultipartHelper.extractContents(parts)); + } + + @Test + void extractContentsIncludesFileWithEmptyFilename() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn(""); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("data"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsReadsFileContent() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("photo.jpg"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("file-content".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("file-content"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsTruncatesAtMaxContentBytes() throws IOException { + byte[] large = new byte[MultipartHelper.MAX_CONTENT_BYTES + 1]; + Arrays.fill(large, (byte) 'A'); + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("big.bin"); + when(p.getInputStream()).thenReturn(new ByteArrayInputStream(large)); + when(p.getContentType()).thenReturn(null); + List contents = MultipartHelper.extractContents(singletonList(p)); + assertEquals(1, contents.size()); + assertEquals(MultipartHelper.MAX_CONTENT_BYTES, contents.get(0).length()); + } + + @Test + void extractContentsReturnsEmptyStringOnIOException() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file.txt"); + when(p.getInputStream()).thenThrow(new IOException("simulated")); + assertEquals(singletonList(""), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsCappsAtMaxFilesToInspect() throws IOException { + int count = MultipartHelper.MAX_FILES_TO_INSPECT + 1; + List parts = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file" + i + ".txt"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("c".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn(null); + parts.add(p); + } + List contents = MultipartHelper.extractContents(parts); + assertEquals(MultipartHelper.MAX_FILES_TO_INSPECT, contents.size()); + } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/PartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/PartHelper.java index 8fc2a44f22d..462b132e492 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/PartHelper.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/PartHelper.java @@ -3,11 +3,13 @@ import static datadog.trace.api.gateway.Events.EVENTS; import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.api.Config; import datadog.trace.api.gateway.BlockResponseFunction; import datadog.trace.api.gateway.CallbackProvider; import datadog.trace.api.gateway.Flow; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.api.http.MultipartContentDecoder; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -38,6 +40,9 @@ public class PartHelper { private static final Logger log = LoggerFactory.getLogger(PartHelper.class); + public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes(); + public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount(); + private PartHelper() {} // Lazily resolves MultiPartInputStream.getParts() as a MethodHandle on first class access. @@ -257,6 +262,75 @@ public static BlockingException fireFilenamesEvent(Collection parts, RequestC return null; } + /** + * Extracts file content from a collection of multipart {@link Part}s. Form fields (those without + * a {@code filename} parameter in the {@code Content-Disposition} header) are skipped. Reads up + * to {@link #MAX_CONTENT_BYTES} bytes per part, up to {@link #MAX_FILES_TO_INSPECT} parts total. + * + * @return list of decoded content strings; never {@code null}, may be empty + */ + public static List extractContents(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List contents = new ArrayList<>(Math.min(parts.size(), MAX_FILES_TO_INSPECT)); + for (Object obj : parts) { + if (contents.size() >= MAX_FILES_TO_INSPECT) { + break; + } + try { + Part part = (Part) obj; + if (filenameFromPart(part) == null) { + continue; // form field — skip + } + contents.add(readFileContent(part)); + } catch (Exception e) { + log.debug("extractContents: skipping malformed part", e); + } + } + return contents; + } + + private static String readFileContent(Part part) { + try (InputStream is = part.getInputStream()) { + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, part.getContentType()); + } catch (Exception e) { + log.debug("readFileContent: stream read failed", e); + return ""; + } + } + + /** + * Fires the {@code requestFilesContent} IG event for file-upload parts in {@code parts} and + * returns a {@link BlockingException} if the WAF requests blocking, or {@code null} otherwise. + */ + public static BlockingException fireFilesContentEvent( + Collection parts, RequestContext reqCtx) { + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesContent()); + if (callback == null) { + return null; + } + List contents = extractContents(parts); + if (contents.isEmpty()) { + return null; + } + Flow flow = callback.apply(reqCtx, contents); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + if (brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba)) { + reqCtx.getTraceSegment().effectivelyBlocked(); + return new BlockingException("Blocked request (multipart file content)"); + } + } + } + return null; + } + private static String readPartContent(Part part) { Charset charset = charsetFromContentType(part.getContentType()); try (InputStream is = part.getInputStream()) { diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java index 2dc980dc1df..5f4259942dd 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/main/java/datadog/trace/instrumentation/jetty8/RequestGetPartsInstrumentation.java @@ -149,7 +149,11 @@ static void after( } BlockingException bodyBlock = PartHelper.fireBodyProcessedEvent(parts, reqCtx); BlockingException filenamesBlock = PartHelper.fireFilenamesEvent(parts, reqCtx); - t = bodyBlock != null ? bodyBlock : filenamesBlock; + BlockingException contentBlock = + bodyBlock == null && filenamesBlock == null + ? PartHelper.fireFilesContentEvent(parts, reqCtx) + : null; + t = bodyBlock != null ? bodyBlock : (filenamesBlock != null ? filenamesBlock : contentBlock); } } @@ -192,7 +196,11 @@ static void after( } BlockingException bodyBlock = PartHelper.fireBodyProcessedEvent(parts, reqCtx); BlockingException filenamesBlock = PartHelper.fireFilenamesEvent(parts, reqCtx); - t = bodyBlock != null ? bodyBlock : filenamesBlock; + BlockingException contentBlock = + bodyBlock == null && filenamesBlock == null + ? PartHelper.fireFilesContentEvent(parts, reqCtx) + : null; + t = bodyBlock != null ? bodyBlock : (filenamesBlock != null ? filenamesBlock : contentBlock); } } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/java/datadog/trace/instrumentation/jetty8/PartHelperTest.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/java/datadog/trace/instrumentation/jetty8/PartHelperTest.java index 03c1ae51827..d5e52c12dd1 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/java/datadog/trace/instrumentation/jetty8/PartHelperTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-8.1.3/src/test/java/datadog/trace/instrumentation/jetty8/PartHelperTest.java @@ -12,6 +12,8 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -236,6 +238,77 @@ void extractFormFieldsDecodesFieldUsingContentTypeCharset() throws IOException { assertEquals(singletonList("café"), result.get("drink")); } + // ── extractContents ───────────────────────────────────────────────────────── + + @Test + void extractContentsReturnsEmptyListForNull() { + assertEquals(emptyList(), PartHelper.extractContents(null)); + } + + @Test + void extractContentsReturnsEmptyListForEmpty() { + assertEquals(emptyList(), PartHelper.extractContents(emptyList())); + } + + @Test + void extractContentsSkipsFormFieldParts() { + List parts = asList(formField("a"), formField("b")); + assertEquals(emptyList(), PartHelper.extractContents(parts)); + } + + @Test + void extractContentsIncludesFileWithEmptyFilename() throws IOException { + List parts = singletonList(emptyFilenamePart("upload")); + Part p = parts.get(0); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("data"), PartHelper.extractContents(parts)); + } + + @Test + void extractContentsReadsFileContent() throws IOException { + Part p = filePart("photo.jpg"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("file-content".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("file-content"), PartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsTruncatesAtMaxContentBytes() throws IOException { + byte[] large = new byte[PartHelper.MAX_CONTENT_BYTES + 1]; + Arrays.fill(large, (byte) 'A'); + Part p = filePart("big.bin"); + when(p.getInputStream()).thenReturn(new ByteArrayInputStream(large)); + when(p.getContentType()).thenReturn(null); + List contents = PartHelper.extractContents(singletonList(p)); + assertEquals(1, contents.size()); + assertEquals(PartHelper.MAX_CONTENT_BYTES, contents.get(0).length()); + } + + @Test + void extractContentsReturnsEmptyStringOnIOException() throws IOException { + Part p = filePart("file.txt"); + when(p.getInputStream()).thenThrow(new IOException("simulated")); + assertEquals(singletonList(""), PartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsCappsAtMaxFilesToInspect() throws IOException { + int count = PartHelper.MAX_FILES_TO_INSPECT + 1; + List parts = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Part p = filePart("file" + i + ".txt"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("c".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn(null); + parts.add(p); + } + List contents = PartHelper.extractContents(parts); + assertEquals(PartHelper.MAX_FILES_TO_INSPECT, contents.size()); + } + // ── getAllParts ───────────────────────────────────────────────────────────── @Test diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java index 24780c46110..9d0e2bb4032 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/MultipartHelper.java @@ -3,21 +3,31 @@ import static datadog.trace.api.gateway.Events.EVENTS; import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.api.Config; import datadog.trace.api.gateway.BlockResponseFunction; import datadog.trace.api.gateway.CallbackProvider; import datadog.trace.api.gateway.Flow; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.api.http.MultipartContentDecoder; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.function.BiFunction; import javax.servlet.http.Part; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class MultipartHelper { + public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes(); + public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount(); + + private static final Logger log = LoggerFactory.getLogger(MultipartHelper.class); + private MultipartHelper() {} /** @@ -39,11 +49,80 @@ public static List extractFilenames(Collection parts) { } } catch (Exception ignored) { // malformed or inaccessible part — skip and continue with remaining parts + log.debug("extractFilenames: skipping malformed part", ignored); } } return filenames; } + /** + * Extracts file content from a collection of multipart {@link Part}s. Form fields (those with a + * {@code null} submitted filename) are skipped. Reads up to {@link #MAX_CONTENT_BYTES} bytes per + * part, up to {@link #MAX_FILES_TO_INSPECT} parts total. + * + * @return list of decoded content strings; never {@code null}, may be empty + */ + public static List extractContents(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List contents = new ArrayList<>(Math.min(parts.size(), MAX_FILES_TO_INSPECT)); + for (Part part : parts) { + if (contents.size() >= MAX_FILES_TO_INSPECT) { + break; + } + try { + if (part.getSubmittedFileName() == null) { + continue; // form field — skip + } + contents.add(readFileContent(part)); + } catch (Exception ignored) { + log.debug("extractContents: skipping malformed part", ignored); + } + } + return contents; + } + + private static String readFileContent(Part part) { + try (InputStream is = part.getInputStream()) { + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, part.getContentType()); + } catch (Exception e) { + log.debug("readFileContent: stream read failed", e); + return ""; + } + } + + /** + * Fires the {@code requestFilesContent} IG event and returns a {@link BlockingException} if the + * WAF requests blocking, or {@code null} otherwise. + */ + public static BlockingException fireFilesContentEvent( + Collection parts, RequestContext reqCtx) { + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesContent()); + if (callback == null) { + return null; + } + List contents = extractContents(parts); + if (contents.isEmpty()) { + return null; + } + Flow flow = callback.apply(reqCtx, contents); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + if (brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba)) { + reqCtx.getTraceSegment().effectivelyBlocked(); + return new BlockingException("Blocked request (multipart file content)"); + } + } + } + return null; + } + /** * Fires the {@code requestFilesFilenames} IG event and returns a {@link BlockingException} if the * WAF requests blocking, or {@code null} otherwise. diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java index 902b792fee2..53dcc22950a 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/main/java/datadog/trace/instrumentation/jetty92/RequestExtractContentParametersInstrumentation.java @@ -181,6 +181,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } @@ -209,6 +212,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/java/datadog/trace/instrumentation/jetty92/MultipartHelperTest.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/java/datadog/trace/instrumentation/jetty92/MultipartHelperTest.java index 7ef06eede04..376e1bd6305 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/java/datadog/trace/instrumentation/jetty92/MultipartHelperTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.2/src/test/java/datadog/trace/instrumentation/jetty92/MultipartHelperTest.java @@ -7,6 +7,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.servlet.http.Part; import org.junit.jupiter.api.Test; @@ -64,4 +69,79 @@ private Part part(String submittedFileName) { when(p.getSubmittedFileName()).thenReturn(submittedFileName); return p; } + + // ── extractContents ───────────────────────────────────────────────────────── + + @Test + void extractContentsReturnsEmptyListForNull() { + assertEquals(emptyList(), MultipartHelper.extractContents(null)); + } + + @Test + void extractContentsReturnsEmptyListForEmpty() { + assertEquals(emptyList(), MultipartHelper.extractContents(emptyList())); + } + + @Test + void extractContentsSkipsFormFieldParts() { + List parts = asList(part(null), part(null)); + assertEquals(emptyList(), MultipartHelper.extractContents(parts)); + } + + @Test + void extractContentsIncludesFileWithEmptyFilename() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn(""); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("data"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsReadsFileContent() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("photo.jpg"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("file-content".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("file-content"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsTruncatesAtMaxContentBytes() throws IOException { + byte[] large = new byte[MultipartHelper.MAX_CONTENT_BYTES + 1]; + Arrays.fill(large, (byte) 'A'); + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("big.bin"); + when(p.getInputStream()).thenReturn(new ByteArrayInputStream(large)); + when(p.getContentType()).thenReturn(null); + List contents = MultipartHelper.extractContents(singletonList(p)); + assertEquals(1, contents.size()); + assertEquals(MultipartHelper.MAX_CONTENT_BYTES, contents.get(0).length()); + } + + @Test + void extractContentsReturnsEmptyStringOnIOException() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file.txt"); + when(p.getInputStream()).thenThrow(new IOException("simulated")); + assertEquals(singletonList(""), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsCappsAtMaxFilesToInspect() throws IOException { + int count = MultipartHelper.MAX_FILES_TO_INSPECT + 1; + List parts = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file" + i + ".txt"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("c".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn(null); + parts.add(p); + } + List contents = MultipartHelper.extractContents(parts); + assertEquals(MultipartHelper.MAX_FILES_TO_INSPECT, contents.size()); + } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java index dedd0b2705b..00cd8f518e8 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/MultipartHelper.java @@ -3,21 +3,31 @@ import static datadog.trace.api.gateway.Events.EVENTS; import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.api.Config; import datadog.trace.api.gateway.BlockResponseFunction; import datadog.trace.api.gateway.CallbackProvider; import datadog.trace.api.gateway.Flow; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.api.http.MultipartContentDecoder; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.function.BiFunction; import javax.servlet.http.Part; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class MultipartHelper { + public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes(); + public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount(); + + private static final Logger log = LoggerFactory.getLogger(MultipartHelper.class); + private MultipartHelper() {} /** @@ -39,11 +49,80 @@ public static List extractFilenames(Collection parts) { } } catch (Exception ignored) { // malformed or inaccessible part — skip and continue with remaining parts + log.debug("extractFilenames: skipping malformed part", ignored); } } return filenames; } + /** + * Extracts file content from a collection of multipart {@link Part}s. Form fields (those with a + * {@code null} submitted filename) are skipped. Reads up to {@link #MAX_CONTENT_BYTES} bytes per + * part, up to {@link #MAX_FILES_TO_INSPECT} parts total. + * + * @return list of decoded content strings; never {@code null}, may be empty + */ + public static List extractContents(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List contents = new ArrayList<>(Math.min(parts.size(), MAX_FILES_TO_INSPECT)); + for (Part part : parts) { + if (contents.size() >= MAX_FILES_TO_INSPECT) { + break; + } + try { + if (part.getSubmittedFileName() == null) { + continue; // form field — skip + } + contents.add(readFileContent(part)); + } catch (Exception ignored) { + log.debug("extractContents: skipping malformed part", ignored); + } + } + return contents; + } + + private static String readFileContent(Part part) { + try (InputStream is = part.getInputStream()) { + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, part.getContentType()); + } catch (Exception e) { + log.debug("readFileContent: stream read failed", e); + return ""; + } + } + + /** + * Fires the {@code requestFilesContent} IG event and returns a {@link BlockingException} if the + * WAF requests blocking, or {@code null} otherwise. + */ + public static BlockingException fireFilesContentEvent( + Collection parts, RequestContext reqCtx) { + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesContent()); + if (callback == null) { + return null; + } + List contents = extractContents(parts); + if (contents.isEmpty()) { + return null; + } + Flow flow = callback.apply(reqCtx, contents); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + if (brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba)) { + reqCtx.getTraceSegment().effectivelyBlocked(); + return new BlockingException("Blocked request (multipart file content)"); + } + } + } + return null; + } + /** * Fires the {@code requestFilesFilenames} IG event and returns a {@link BlockingException} if the * WAF requests blocking, or {@code null} otherwise. diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java index 57948e5d45a..8de68cbff25 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/main/java/datadog/trace/instrumentation/jetty93/RequestExtractContentParametersInstrumentation.java @@ -156,6 +156,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } @@ -185,6 +188,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/java/datadog/trace/instrumentation/jetty93/MultipartHelperTest.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/java/datadog/trace/instrumentation/jetty93/MultipartHelperTest.java index 00250efe19a..9f580412ae8 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/java/datadog/trace/instrumentation/jetty93/MultipartHelperTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.3/src/test/java/datadog/trace/instrumentation/jetty93/MultipartHelperTest.java @@ -7,6 +7,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.servlet.http.Part; import org.junit.jupiter.api.Test; @@ -64,4 +69,79 @@ private Part part(String submittedFileName) { when(p.getSubmittedFileName()).thenReturn(submittedFileName); return p; } + + // ── extractContents ───────────────────────────────────────────────────────── + + @Test + void extractContentsReturnsEmptyListForNull() { + assertEquals(emptyList(), MultipartHelper.extractContents(null)); + } + + @Test + void extractContentsReturnsEmptyListForEmpty() { + assertEquals(emptyList(), MultipartHelper.extractContents(emptyList())); + } + + @Test + void extractContentsSkipsFormFieldParts() { + List parts = asList(part(null), part(null)); + assertEquals(emptyList(), MultipartHelper.extractContents(parts)); + } + + @Test + void extractContentsIncludesFileWithEmptyFilename() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn(""); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("data"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsReadsFileContent() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("photo.jpg"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("file-content".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("file-content"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsTruncatesAtMaxContentBytes() throws IOException { + byte[] large = new byte[MultipartHelper.MAX_CONTENT_BYTES + 1]; + Arrays.fill(large, (byte) 'A'); + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("big.bin"); + when(p.getInputStream()).thenReturn(new ByteArrayInputStream(large)); + when(p.getContentType()).thenReturn(null); + List contents = MultipartHelper.extractContents(singletonList(p)); + assertEquals(1, contents.size()); + assertEquals(MultipartHelper.MAX_CONTENT_BYTES, contents.get(0).length()); + } + + @Test + void extractContentsReturnsEmptyStringOnIOException() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file.txt"); + when(p.getInputStream()).thenThrow(new IOException("simulated")); + assertEquals(singletonList(""), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsCappsAtMaxFilesToInspect() throws IOException { + int count = MultipartHelper.MAX_FILES_TO_INSPECT + 1; + List parts = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file" + i + ".txt"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("c".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn(null); + parts.add(p); + } + List contents = MultipartHelper.extractContents(parts); + assertEquals(MultipartHelper.MAX_FILES_TO_INSPECT, contents.size()); + } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java index 7cc4ac9e930..f17fbbb4f28 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/MultipartHelper.java @@ -3,21 +3,31 @@ import static datadog.trace.api.gateway.Events.EVENTS; import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.api.Config; import datadog.trace.api.gateway.BlockResponseFunction; import datadog.trace.api.gateway.CallbackProvider; import datadog.trace.api.gateway.Flow; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.api.http.MultipartContentDecoder; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.function.BiFunction; import javax.servlet.http.Part; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class MultipartHelper { + public static final int MAX_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes(); + public static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount(); + + private static final Logger log = LoggerFactory.getLogger(MultipartHelper.class); + private MultipartHelper() {} /** @@ -39,11 +49,80 @@ public static List extractFilenames(Collection parts) { } } catch (Exception ignored) { // malformed or inaccessible part — skip and continue with remaining parts + log.debug("extractFilenames: skipping malformed part", ignored); } } return filenames; } + /** + * Extracts file content from a collection of multipart {@link Part}s. Form fields (those with a + * {@code null} submitted filename) are skipped. Reads up to {@link #MAX_CONTENT_BYTES} bytes per + * part, up to {@link #MAX_FILES_TO_INSPECT} parts total. + * + * @return list of decoded content strings; never {@code null}, may be empty + */ + public static List extractContents(Collection parts) { + if (parts == null || parts.isEmpty()) { + return Collections.emptyList(); + } + List contents = new ArrayList<>(Math.min(parts.size(), MAX_FILES_TO_INSPECT)); + for (Part part : parts) { + if (contents.size() >= MAX_FILES_TO_INSPECT) { + break; + } + try { + if (part.getSubmittedFileName() == null) { + continue; // form field — skip + } + contents.add(readFileContent(part)); + } catch (Exception ignored) { + log.debug("extractContents: skipping malformed part", ignored); + } + } + return contents; + } + + private static String readFileContent(Part part) { + try (InputStream is = part.getInputStream()) { + return MultipartContentDecoder.readInputStream(is, MAX_CONTENT_BYTES, part.getContentType()); + } catch (Exception e) { + log.debug("readFileContent: stream read failed", e); + return ""; + } + } + + /** + * Fires the {@code requestFilesContent} IG event and returns a {@link BlockingException} if the + * WAF requests blocking, or {@code null} otherwise. + */ + public static BlockingException fireFilesContentEvent( + Collection parts, RequestContext reqCtx) { + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesContent()); + if (callback == null) { + return null; + } + List contents = extractContents(parts); + if (contents.isEmpty()) { + return null; + } + Flow flow = callback.apply(reqCtx, contents); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + if (brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba)) { + reqCtx.getTraceSegment().effectivelyBlocked(); + return new BlockingException("Blocked request (multipart file content)"); + } + } + } + return null; + } + /** * Fires the {@code requestFilesFilenames} IG event and returns a {@link BlockingException} if the * WAF requests blocking, or {@code null} otherwise. diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java index 8b69460c16b..01465423518 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/main/java/datadog/trace/instrumentation/jetty94/RequestExtractContentParametersInstrumentation.java @@ -168,6 +168,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } @@ -194,6 +197,9 @@ static void after( return; } t = MultipartHelper.fireFilenamesEvent(parts, reqCtx); + if (t == null) { + t = MultipartHelper.fireFilesContentEvent(parts, reqCtx); + } } } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/java/datadog/trace/instrumentation/jetty94/MultipartHelperTest.java b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/java/datadog/trace/instrumentation/jetty94/MultipartHelperTest.java index 1b632fbb8fa..a32058a8624 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/java/datadog/trace/instrumentation/jetty94/MultipartHelperTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-appsec/jetty-appsec-9.4/src/test/java/datadog/trace/instrumentation/jetty94/MultipartHelperTest.java @@ -7,6 +7,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.servlet.http.Part; import org.junit.jupiter.api.Test; @@ -64,4 +69,79 @@ private Part part(String submittedFileName) { when(p.getSubmittedFileName()).thenReturn(submittedFileName); return p; } + + // ── extractContents ───────────────────────────────────────────────────────── + + @Test + void extractContentsReturnsEmptyListForNull() { + assertEquals(emptyList(), MultipartHelper.extractContents(null)); + } + + @Test + void extractContentsReturnsEmptyListForEmpty() { + assertEquals(emptyList(), MultipartHelper.extractContents(emptyList())); + } + + @Test + void extractContentsSkipsFormFieldParts() { + List parts = asList(part(null), part(null)); + assertEquals(emptyList(), MultipartHelper.extractContents(parts)); + } + + @Test + void extractContentsIncludesFileWithEmptyFilename() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn(""); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("data"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsReadsFileContent() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("photo.jpg"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("file-content".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn("text/plain; charset=UTF-8"); + assertEquals(singletonList("file-content"), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsTruncatesAtMaxContentBytes() throws IOException { + byte[] large = new byte[MultipartHelper.MAX_CONTENT_BYTES + 1]; + Arrays.fill(large, (byte) 'A'); + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("big.bin"); + when(p.getInputStream()).thenReturn(new ByteArrayInputStream(large)); + when(p.getContentType()).thenReturn(null); + List contents = MultipartHelper.extractContents(singletonList(p)); + assertEquals(1, contents.size()); + assertEquals(MultipartHelper.MAX_CONTENT_BYTES, contents.get(0).length()); + } + + @Test + void extractContentsReturnsEmptyStringOnIOException() throws IOException { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file.txt"); + when(p.getInputStream()).thenThrow(new IOException("simulated")); + assertEquals(singletonList(""), MultipartHelper.extractContents(singletonList(p))); + } + + @Test + void extractContentsCappsAtMaxFilesToInspect() throws IOException { + int count = MultipartHelper.MAX_FILES_TO_INSPECT + 1; + List parts = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Part p = mock(Part.class); + when(p.getSubmittedFileName()).thenReturn("file" + i + ".txt"); + when(p.getInputStream()) + .thenReturn(new ByteArrayInputStream("c".getBytes(StandardCharsets.UTF_8))); + when(p.getContentType()).thenReturn(null); + parts.add(p); + } + List contents = MultipartHelper.extractContents(parts); + assertEquals(MultipartHelper.MAX_FILES_TO_INSPECT, contents.size()); + } } diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy index 2726574ec83..5f7f5a2c10b 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-10.0/src/test/groovy/datadog/trace/instrumentation/jetty10/Jetty10Test.groovy @@ -100,6 +100,11 @@ abstract class Jetty10Test extends HttpServerTest { true } + @Override + boolean testBodyFilesContent() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy index 80afb31077a..6c3c72b67e9 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-11.0/src/test/groovy/Jetty11Test.groovy @@ -82,6 +82,11 @@ abstract class Jetty11Test extends HttpServerTest { true } + @Override + boolean testBodyFilesContent() { + true + } + @Override boolean testBlocking() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.java b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.java index b852aa3a184..2188513a81c 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-7.6/src/test/groovy/Jetty8LatestDepForkedTest.java @@ -52,6 +52,11 @@ public boolean testBodyFilenamesCalledOnceCombined() { return false; } + @Override + public boolean testBodyFilesContent() { + return true; + } + static class Jetty8TestHandler extends AbstractHandler { private static final MultipartConfigElement MULTIPART_CONFIG = new MultipartConfigElement(System.getProperty("java.io.tmpdir")); diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty92LatestDepForkedTest.java b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty92LatestDepForkedTest.java index dd9e65e8b72..cbddff84bec 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty92LatestDepForkedTest.java +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.0.4/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty92LatestDepForkedTest.java @@ -34,6 +34,11 @@ public boolean testBodyFilenamesCalledOnce() { public boolean testBodyFilenamesCalledOnceCombined() { return true; } + + @Override + public boolean testBodyFilesContent() { + return true; + } } @EnabledIfSystemProperty(named = "test.dd.jetty92", matches = ".+") diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy index 9bdc9e1e469..3c4c45a9d02 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.3/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy @@ -99,6 +99,11 @@ abstract class Jetty9Test extends HttpServerTest { true } + @Override + boolean testBodyFilesContent() { + true + } + @Override boolean testSessionId() { true diff --git a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy index 8a18ccbc652..fcbe105bc6f 100644 --- a/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy +++ b/dd-java-agent/instrumentation/jetty/jetty-server/jetty-server-9.4.21/src/test/groovy/datadog/trace/instrumentation/jetty9/Jetty9Test.groovy @@ -100,6 +100,11 @@ abstract class Jetty9Test extends HttpServerTest { true } + @Override + boolean testBodyFilesContent() { + true + } + @Override boolean testSessionId() { true