diff --git a/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/build.gradle b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/build.gradle index f5542d48255..1d3cc39c2c4 100644 --- a/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/build.gradle +++ b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/build.gradle @@ -15,6 +15,13 @@ muzzle { extraDependency 'org.apache.tomcat:tomcat-catalina:7.0.4' assertInverse = true } + pass { + name = 'glassfish' + group = 'org.glassfish.main.extras' + module = 'glassfish-embedded-all' + versions = '[4.0, 6.1.0)' // GlassFish 6.1.0+ uses jakarta.* namespace; our advice uses javax.servlet.http.Part + assertInverse = true + } } apply from: "$rootDir/gradle/java.gradle" @@ -22,10 +29,15 @@ apply from: "$rootDir/gradle/java.gradle" dependencies { compileOnly group: 'org.apache.tomcat', name: 'tomcat-catalina', version: '7.0.4' compileOnly group: 'org.apache.tomcat', name: 'tomcat-coyote', version: '7.0.4' + // Servlet 3.1 API needed to reference Part.getSubmittedFileName() in GlassFishMultipartInstrumentation. + // tomcat-catalina:7.0.4 provides only Servlet 3.0 (no getSubmittedFileName); GlassFish 4+ is Servlet 3.1. + compileOnly group: 'javax.servlet', name: 'javax.servlet-api', version: '3.1.0' implementation project(':dd-java-agent:instrumentation:tomcat:tomcat-common') testImplementation group: 'org.apache.tomcat', name: 'tomcat-catalina', version: '7.0.4' testImplementation group: 'org.apache.tomcat', name: 'tomcat-coyote', version: '7.0.4' + testImplementation group: 'javax.servlet', name: 'javax.servlet-api', version: '3.1.0' + testImplementation libs.bundles.mockito } // testing happens in tomcat-5.5 module diff --git a/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/main/java/datadog/trace/instrumentation/tomcat7/GlassFishBlockingHelper.java b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/main/java/datadog/trace/instrumentation/tomcat7/GlassFishBlockingHelper.java new file mode 100644 index 00000000000..5062468ae8d --- /dev/null +++ b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/main/java/datadog/trace/instrumentation/tomcat7/GlassFishBlockingHelper.java @@ -0,0 +1,84 @@ +package datadog.trace.instrumentation.tomcat7; + +import datadog.appsec.api.blocking.BlockingContentType; +import datadog.trace.api.Config; +import datadog.trace.api.gateway.BlockResponseFunction; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.bootstrap.blocking.BlockingActionHelper; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public final class GlassFishBlockingHelper { + + public static final int MAX_FILE_CONTENT_COUNT = Config.get().getAppSecMaxFileContentCount(); + public static final int MAX_FILE_CONTENT_BYTES = Config.get().getAppSecMaxFileContentBytes(); + + /** + * Attempts to commit a blocking response via the registered {@link BlockResponseFunction} or via + * the Servlet API fallback, then marks the trace segment as effectively blocked. + * + *

Returns {@code true} if the response was committed (regardless of whether {@link + * datadog.trace.api.internal.TraceSegment#effectivelyBlocked()} succeeded). Returns {@code false} + * if no response could be committed. + */ + public static boolean tryBlock( + RequestContext reqCtx, + HttpServletRequest fallbackReq, + HttpServletResponse fallbackResp, + Flow.Action.RequestBlockingAction rba) { + try { + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba); + } else if (!commitBlocking(fallbackReq, fallbackResp, rba)) { + return false; + } + } catch (Exception ignored) { + return false; + } + // Response was committed — mark as blocked on a best-effort basis. + // effectivelyBlocked() can throw if the span is already finished; that must not suppress the + // true return value since the response has already been sent to the client. + try { + reqCtx.getTraceSegment().effectivelyBlocked(); + } catch (Exception ignored) { + } + return true; + } + + public static boolean commitBlocking( + HttpServletRequest request, + HttpServletResponse response, + Flow.Action.RequestBlockingAction rba) { + if (response == null) { + return false; + } + try { + if (response.isCommitted()) { + return false; + } + response.reset(); + response.setStatus(BlockingActionHelper.getHttpCode(rba.getStatusCode())); + for (Map.Entry e : rba.getExtraHeaders().entrySet()) { + response.setHeader(e.getKey(), e.getValue()); + } + if (rba.getBlockingContentType() != BlockingContentType.NONE) { + String accept = request != null ? request.getHeader("Accept") : null; + BlockingActionHelper.TemplateType type = + BlockingActionHelper.determineTemplateType(rba.getBlockingContentType(), accept); + byte[] body = BlockingActionHelper.getTemplate(type, rba.getSecurityResponseId()); + if (body != null) { + response.setHeader("Content-Type", BlockingActionHelper.getContentType(type)); + response.setHeader("Content-Length", Integer.toString(body.length)); + response.getOutputStream().write(body); + } + } + response.flushBuffer(); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/main/java/datadog/trace/instrumentation/tomcat7/GlassFishMultipartInstrumentation.java b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/main/java/datadog/trace/instrumentation/tomcat7/GlassFishMultipartInstrumentation.java new file mode 100644 index 00000000000..779b2aed8c4 --- /dev/null +++ b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/main/java/datadog/trace/instrumentation/tomcat7/GlassFishMultipartInstrumentation.java @@ -0,0 +1,198 @@ +package datadog.trace.instrumentation.tomcat7; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.api.gateway.Events.EVENTS; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +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.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +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.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.Part; +import net.bytebuddy.asm.Advice; + +/** + * GlassFish/Payara does not have {@code Request.parseParts()} — instead {@code Request.getParts()} + * delegates to {@code org.apache.catalina.fileupload.Multipart.getParts()}. This instrumentation + * hooks that GlassFish-specific class to report uploaded file names and contents to the AppSec WAF + * via the {@code requestFilesFilenames} and {@code requestFilesContent} IG events. + * + *

Because {@code org.apache.catalina.fileupload.Multipart} does not exist in standard Tomcat, + * this instrumentation is automatically skipped by ByteBuddy on non-GlassFish containers. + * + *

This advice casts each {@code Part} through the {@code javax.servlet.http.Part} interface + * (which {@code org.apache.catalina.fileupload.PartItem} implements) to avoid Java module-system + * access restrictions that prevent reflective invocation of methods on GlassFish-internal classes. + */ +@AutoService(InstrumenterModule.class) +public class GlassFishMultipartInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public GlassFishMultipartInstrumentation() { + super("tomcat"); + } + + @Override + public String muzzleDirective() { + return "glassfish"; + } + + @Override + public String instrumentedType() { + return "org.apache.catalina.fileupload.Multipart"; + } + + @Override + public String[] helperClassNames() { + return new String[] { + "datadog.trace.instrumentation.tomcat7.GlassFishBlockingHelper", + }; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("getParts").and(takesArguments(0)).and(isPublic()), + getClass().getName() + "$GetPartsAdvice"); + } + + public static class GetPartsAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.This Object thisMultipart, + @Advice.Return(readOnly = false) Collection parts, + @Advice.Thrown Throwable t) { + if (t != null || parts == null || parts.isEmpty()) { + return; + } + + AgentSpan agentSpan = AgentTracer.activeSpan(); + if (agentSpan == null) { + return; + } + RequestContext reqCtx = agentSpan.getRequestContext(); + if (reqCtx == null || reqCtx.getData(RequestContextSlot.APPSEC) == null) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> filenamesCb = + cbp.getCallback(EVENTS.requestFilesFilenames()); + BiFunction, Flow> contentCb = + cbp.getCallback(EVENTS.requestFilesContent()); + if (filenamesCb == null && contentCb == null) { + return; + } + + // Extract servlet request/response for fallback blocking when no BlockResponseFunction is + // registered (Payara: TomcatServerInstrumentation is muzzled out for Payara's response type). + // setAccessible works here because this code is inlined into Multipart.getParts() — + // the same module as the private field's owner class. + HttpServletRequest fallbackReq = null; + HttpServletResponse fallbackResp = null; + try { + Field f = thisMultipart.getClass().getDeclaredField("request"); + f.setAccessible(true); + Object catReq = f.get(thisMultipart); + if (catReq instanceof HttpServletRequest) { + fallbackReq = (HttpServletRequest) catReq; + } + if (catReq != null) { + Method m = catReq.getClass().getMethod("getResponse"); + Object catResp = m.invoke(catReq); + if (catResp instanceof HttpServletResponse) { + fallbackResp = (HttpServletResponse) catResp; + } + } + } catch (Exception ignored) { + } + + int maxFiles = GlassFishBlockingHelper.MAX_FILE_CONTENT_COUNT; + int maxBytes = GlassFishBlockingHelper.MAX_FILE_CONTENT_BYTES; + + List filenames = null; + List contents = null; + + for (Object partObj : parts) { + try { + if (!(partObj instanceof Part)) { + continue; + } + Part part = (Part) partObj; + String filename = part.getSubmittedFileName(); + // null means no filename parameter → form field, skip + // empty string means filename="" was sent → file upload without a name + if (filename == null) { + continue; + } + if (filenamesCb != null && !filename.isEmpty()) { + if (filenames == null) { + filenames = new ArrayList<>(); + } + filenames.add(filename); + } + if (contentCb != null) { + if (contents == null) { + contents = new ArrayList<>(); + } + if (contents.size() < maxFiles) { + try (InputStream is = part.getInputStream()) { + contents.add( + MultipartContentDecoder.readInputStream(is, maxBytes, part.getContentType())); + } catch (Exception ignored) { + contents.add(""); + } + } + } + } catch (Exception ignored) { + } + } + + boolean blocked = false; + + if (filenames != null && !filenames.isEmpty() && filenamesCb != null) { + Flow flow = filenamesCb.apply(reqCtx, filenames); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + if (GlassFishBlockingHelper.tryBlock( + reqCtx, fallbackReq, fallbackResp, (Flow.Action.RequestBlockingAction) action)) { + parts = Collections.emptyList(); + blocked = true; + } + } + } + + if (!blocked && contents != null && !contents.isEmpty() && contentCb != null) { + Flow contentFlow = contentCb.apply(reqCtx, contents); + Flow.Action contentAction = contentFlow.getAction(); + if (contentAction instanceof Flow.Action.RequestBlockingAction) { + if (GlassFishBlockingHelper.tryBlock( + reqCtx, + fallbackReq, + fallbackResp, + (Flow.Action.RequestBlockingAction) contentAction)) { + parts = Collections.emptyList(); + } + } + } + } + } +} diff --git a/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/test/java/datadog/trace/instrumentation/tomcat7/GlassFishBlockingHelperTest.java b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/test/java/datadog/trace/instrumentation/tomcat7/GlassFishBlockingHelperTest.java new file mode 100644 index 00000000000..c7d046d8756 --- /dev/null +++ b/dd-java-agent/instrumentation/tomcat/tomcat-appsec/tomcat-appsec-7.0/src/test/java/datadog/trace/instrumentation/tomcat7/GlassFishBlockingHelperTest.java @@ -0,0 +1,211 @@ +package datadog.trace.instrumentation.tomcat7; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.appsec.api.blocking.BlockingContentType; +import datadog.trace.api.gateway.BlockResponseFunction; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.internal.TraceSegment; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +class GlassFishBlockingHelperTest { + + // ------- commitBlocking() ------- + + @Test + void commitBlocking_nullResponse_returnsFalse() { + assertFalse(GlassFishBlockingHelper.commitBlocking(null, null, rba(403))); + } + + @Test + void commitBlocking_committedResponse_returnsFalse() { + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(true); + assertFalse(GlassFishBlockingHelper.commitBlocking(null, resp, rba(403))); + } + + @Test + void commitBlocking_blockingContentTypeNone_setsStatusWithoutBody() throws IOException { + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(false); + + assertTrue( + GlassFishBlockingHelper.commitBlocking( + null, resp, new Flow.Action.RequestBlockingAction(403, BlockingContentType.NONE))); + + verify(resp).setStatus(403); + verify(resp).flushBuffer(); + verify(resp, never()).setHeader(eq("Content-Type"), any()); + verify(resp, never()).getOutputStream(); + } + + @Test + void commitBlocking_withJsonAccept_writesJsonBody() throws IOException { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Accept")).thenReturn("application/json"); + TestServletOutputStream out = new TestServletOutputStream(); + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(false); + when(resp.getOutputStream()).thenReturn(out); + + assertTrue(GlassFishBlockingHelper.commitBlocking(req, resp, rba(403))); + + verify(resp).setStatus(403); + verify(resp).setHeader(eq("Content-Type"), contains("json")); + verify(resp).setHeader(eq("Content-Length"), any()); + assertTrue(out.getBytes().length > 0); + verify(resp).flushBuffer(); + } + + @Test + void commitBlocking_withHtmlAccept_writesHtmlBody() throws IOException { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Accept")).thenReturn("text/html"); + TestServletOutputStream out = new TestServletOutputStream(); + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(false); + when(resp.getOutputStream()).thenReturn(out); + + assertTrue(GlassFishBlockingHelper.commitBlocking(req, resp, rba(403))); + + verify(resp).setHeader(eq("Content-Type"), contains("html")); + assertTrue(out.getBytes().length > 0); + } + + @Test + void commitBlocking_nullRequest_defaultsToJsonBody() throws IOException { + TestServletOutputStream out = new TestServletOutputStream(); + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(false); + when(resp.getOutputStream()).thenReturn(out); + + assertTrue(GlassFishBlockingHelper.commitBlocking(null, resp, rba(403))); + + verify(resp).setStatus(403); + assertTrue(out.getBytes().length > 0); + } + + @Test + void commitBlocking_ioException_returnsFalse() throws IOException { + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(false); + when(resp.getOutputStream()).thenThrow(new IOException("stream error")); + + assertFalse(GlassFishBlockingHelper.commitBlocking(null, resp, rba(403))); + } + + // ------- tryBlock() ------- + + @Test + void tryBlock_withBrf_commitsViaFunctionAndReturnsTrue() throws Exception { + TraceSegment segment = mock(TraceSegment.class); + BlockResponseFunction brf = mock(BlockResponseFunction.class); + RequestContext reqCtx = mockReqCtx(brf, segment); + + Flow.Action.RequestBlockingAction action = rba(403); + assertTrue(GlassFishBlockingHelper.tryBlock(reqCtx, null, null, action)); + + verify(brf).tryCommitBlockingResponse(segment, action); + verify(segment).effectivelyBlocked(); + } + + @Test + void tryBlock_noBrf_fallbackSucceeds_returnsTrue() throws IOException { + TraceSegment segment = mock(TraceSegment.class); + RequestContext reqCtx = mockReqCtx(null, segment); + TestServletOutputStream out = new TestServletOutputStream(); + HttpServletResponse resp = mock(HttpServletResponse.class); + when(resp.isCommitted()).thenReturn(false); + when(resp.getOutputStream()).thenReturn(out); + + assertTrue(GlassFishBlockingHelper.tryBlock(reqCtx, null, resp, rba(403))); + verify(segment).effectivelyBlocked(); + } + + @Test + void tryBlock_noBrf_nullFallbackResponse_returnsFalse() { + RequestContext reqCtx = mock(RequestContext.class); + when(reqCtx.getBlockResponseFunction()).thenReturn(null); + + assertFalse(GlassFishBlockingHelper.tryBlock(reqCtx, null, null, rba(403))); + verify(reqCtx, never()).getTraceSegment(); + } + + @Test + void tryBlock_brfThrows_returnsFalse() throws Exception { + TraceSegment segment = mock(TraceSegment.class); + BlockResponseFunction brf = mock(BlockResponseFunction.class); + RequestContext reqCtx = mockReqCtx(brf, segment); + doThrow(new RuntimeException("commit failed")) + .when(brf) + .tryCommitBlockingResponse(any(), any(Flow.Action.RequestBlockingAction.class)); + + assertFalse(GlassFishBlockingHelper.tryBlock(reqCtx, null, null, rba(403))); + verify(segment, never()).effectivelyBlocked(); + } + + @Test + void tryBlock_effectivelyBlockedThrows_stillReturnsTrue() throws Exception { + TraceSegment segment = mock(TraceSegment.class); + BlockResponseFunction brf = mock(BlockResponseFunction.class); + RequestContext reqCtx = mockReqCtx(brf, segment); + doThrow(new RuntimeException("span already finished")).when(segment).effectivelyBlocked(); + + assertTrue(GlassFishBlockingHelper.tryBlock(reqCtx, null, null, rba(403))); + } + + // ------- Helpers ------- + + private static Flow.Action.RequestBlockingAction rba(int statusCode) { + return new Flow.Action.RequestBlockingAction(statusCode, BlockingContentType.AUTO); + } + + private static RequestContext mockReqCtx(BlockResponseFunction brf, TraceSegment segment) { + RequestContext reqCtx = mock(RequestContext.class); + when(reqCtx.getBlockResponseFunction()).thenReturn(brf); + when(reqCtx.getTraceSegment()).thenReturn(segment); + return reqCtx; + } + + private static final class TestServletOutputStream extends ServletOutputStream { + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener listener) {} + + @Override + public void write(int b) throws IOException { + buffer.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + buffer.write(b, off, len); + } + + public byte[] getBytes() { + return buffer.toByteArray(); + } + } +}