From 7fec408d5004bc84b2781d7d954298c7ceea8d54 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 4 Mar 2026 13:28:44 +0100 Subject: [PATCH 1/4] fix(appsec): record blocking response content-type centrally in GatewayBridge When a WAF blocking action fires, the normal response-header IG callbacks are bypassed, so http.response.headers.content-type never reaches the span. Instead of patching every framework's blocking handler, intercept the blocking flow result in GatewayBridge.maybePublishRequestData / maybePublishResponseData, compute the deterministic content-type from RequestBlockingAction + accept header, store it on AppSecRequestContext, and write it as a span tag in onRequestEnded(). Co-Authored-By: Claude Sonnet 4.6 --- .../appsec/gateway/AppSecRequestContext.java | 9 ++++ .../datadog/appsec/gateway/GatewayBridge.java | 48 ++++++++++++++++- .../gateway/GatewayBridgeSpecification.groovy | 51 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java index d5df33efa7d..39ad42bdf51 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java @@ -162,6 +162,7 @@ public class AppSecRequestContext implements DataBundle, Closeable { private final AtomicInteger raspMetricsCounter = new AtomicInteger(0); private volatile boolean wafBlocked; + private volatile String blockingResponseContentType; private volatile boolean wafErrors; private volatile boolean wafTruncated; private volatile boolean wafRequestBlockFailure; @@ -237,6 +238,14 @@ public boolean isWafBlocked() { return wafBlocked; } + public void setBlockingResponseContentType(String contentType) { + this.blockingResponseContentType = contentType; + } + + public String getBlockingResponseContentType() { + return blockingResponseContentType; + } + public void setWafErrors() { this.wafErrors = true; } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java index f95e6dfaf2c..c4138c01b6e 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java @@ -26,6 +26,7 @@ import com.datadog.appsec.report.AppSecEvent; import com.datadog.appsec.report.AppSecEventWrapper; import com.datadog.appsec.util.BodyParser; +import datadog.appsec.api.blocking.BlockingContentType; import datadog.trace.api.Config; import datadog.trace.api.ProductTraceSource; import datadog.trace.api.appsec.HttpClientPayload; @@ -929,6 +930,13 @@ private NoopFlow onRequestEnded(RequestContext ctx_, IGSpanInfo spanInfo) { writeResponseHeaders( ctx, traceSeg, RESPONSE_HEADERS_ALLOW_LIST, ctx.getResponseHeaders(), false); } + // For blocking responses the normal response-header collection is bypassed; write the + // content-type that was determined when the blocking action was raised. + String blockingContentType = ctx.getBlockingResponseContentType(); + if (blockingContentType != null) { + traceSeg.setTagTop("http.response.headers.content-type", blockingContentType); + } + // If extracted any derivatives - commit them if (!ctx.commitDerivatives(traceSeg)) { log.debug("Unable to commit, derivatives will be skipped {}", ctx.getDerivativeKeys()); @@ -1230,7 +1238,9 @@ private Flow maybePublishRequestData(AppSecRequestContext ctx) { try { GatewayContext gwCtx = new GatewayContext(false); - return producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx); + Flow flow = producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx); + maybeRecordBlockingContentType(ctx, flow); + return flow; } catch (ExpiredSubscriberInfoException e) { this.initialReqDataSubInfo = null; } @@ -1263,7 +1273,9 @@ private Flow maybePublishResponseData(AppSecRequestContext ctx) { try { GatewayContext gwCtx = new GatewayContext(false); - return producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx); + Flow flow = producerService.publishDataEvent(subInfo, ctx, bundle, gwCtx); + maybeRecordBlockingContentType(ctx, flow); + return flow; } catch (ExpiredSubscriberInfoException e) { respDataSubInfo = null; } @@ -1277,6 +1289,38 @@ private ApiSecurityDownstreamSampler downstreamSampler() { return downstreamSampler; } + private static void maybeRecordBlockingContentType(AppSecRequestContext ctx, Flow flow) { + Flow.Action action = flow.getAction(); + if (!(action instanceof Flow.Action.RequestBlockingAction)) { + return; + } + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockingContentType bct = rba.getBlockingContentType(); + if (bct == BlockingContentType.NONE) { + return; // redirect — no response body + } + String contentType; + if (bct == BlockingContentType.HTML) { + contentType = "text/html;charset=utf-8"; + } else if (bct == BlockingContentType.JSON) { + contentType = "application/json"; + } else { + // AUTO: pick based on the request Accept header + List acceptValues = ctx.getRequestHeaders().get("accept"); + boolean preferHtml = false; + if (acceptValues != null) { + for (String accept : acceptValues) { + if (accept != null && accept.contains("text/html")) { + preferHtml = true; + break; + } + } + } + contentType = preferHtml ? "text/html;charset=utf-8" : "application/json"; + } + ctx.setBlockingResponseContentType(contentType); + } + private static Map> parseQueryStringParams( String queryString, Charset uriEncoding) { if (queryString == null) { diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy index accab2a3365..2f0539faa16 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy @@ -18,6 +18,7 @@ import datadog.trace.api.appsec.MediaType import datadog.trace.api.config.GeneralConfig import datadog.trace.api.function.TriConsumer import datadog.trace.api.function.TriFunction +import datadog.appsec.api.blocking.BlockingContentType import datadog.trace.api.gateway.BlockResponseFunction import datadog.trace.api.gateway.Flow import datadog.trace.api.gateway.IGSpanInfo @@ -1637,6 +1638,56 @@ class GatewayBridgeSpecification extends DDSpecification { } } + void 'blocking response content-type span tag is written for AUTO bct resolved to application/json'() { + setup: + def rba = new Flow.Action.RequestBlockingAction(403, BlockingContentType.AUTO) + def blockingFlow = [getAction: { + rba + }, getResult: { + null + }] as Flow + IGSpanInfo spanInfo = Stub(AgentSpan) { + getTags() >> TagMap.fromMap([:]) + } + + when: + requestMethodURICB.apply(ctx, 'GET', TestURIDataAdapter.create('/')) + reqHeaderCB.accept(ctx, 'accept', 'application/json') + reqHeadersDoneCB.apply(ctx) + eventDispatcher.getDataSubscribers(_) >> nonEmptyDsInfo + eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _ as DataBundle, _ as GatewayContext) >> blockingFlow + requestSocketAddressCB.apply(ctx, '0.0.0.0', 5555) + requestEndedCB.apply(ctx, spanInfo) + + then: + 1 * traceSegment.setTagTop('http.response.headers.content-type', 'application/json') + } + + void 'blocking response content-type span tag is written for AUTO bct resolved to text/html'() { + setup: + def rba = new Flow.Action.RequestBlockingAction(403, BlockingContentType.AUTO) + def blockingFlow = [getAction: { + rba + }, getResult: { + null + }] as Flow + IGSpanInfo spanInfo = Stub(AgentSpan) { + getTags() >> TagMap.fromMap([:]) + } + + when: + requestMethodURICB.apply(ctx, 'GET', TestURIDataAdapter.create('/')) + reqHeaderCB.accept(ctx, 'accept', 'text/html,application/xhtml+xml') + reqHeadersDoneCB.apply(ctx) + eventDispatcher.getDataSubscribers(_) >> nonEmptyDsInfo + eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _ as DataBundle, _ as GatewayContext) >> blockingFlow + requestSocketAddressCB.apply(ctx, '0.0.0.0', 5555) + requestEndedCB.apply(ctx, spanInfo) + + then: + 1 * traceSegment.setTagTop('http.response.headers.content-type', 'text/html;charset=utf-8') + } + static toLowerCaseHeaders(final Map> headers) { return headers.collectEntries { [(it.key.toLowerCase(Locale.ROOT)): it.value] From d3ac6d0e94dd44f3ba18b61e200f8432c8d0c138 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 4 Mar 2026 14:23:18 +0100 Subject: [PATCH 2/4] Fix and more tests --- .../appsec/gateway/AppSecRequestContext.java | 9 +++++ .../datadog/appsec/gateway/GatewayBridge.java | 36 ++++++++----------- .../gateway/GatewayBridgeSpecification.groovy | 5 +++ 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java index 39ad42bdf51..de997b924e0 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java @@ -163,6 +163,7 @@ public class AppSecRequestContext implements DataBundle, Closeable { private volatile boolean wafBlocked; private volatile String blockingResponseContentType; + private volatile int blockingResponseContentLength = -1; private volatile boolean wafErrors; private volatile boolean wafTruncated; private volatile boolean wafRequestBlockFailure; @@ -246,6 +247,14 @@ public String getBlockingResponseContentType() { return blockingResponseContentType; } + public void setBlockingResponseContentLength(int contentLength) { + this.blockingResponseContentLength = contentLength; + } + + public int getBlockingResponseContentLength() { + return blockingResponseContentLength; + } + public void setWafErrors() { this.wafErrors = true; } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java index c4138c01b6e..cee52dfbcce 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java @@ -43,6 +43,7 @@ import datadog.trace.api.telemetry.LoginEvent; import datadog.trace.api.telemetry.RuleType; import datadog.trace.api.telemetry.WafMetricCollector; +import datadog.trace.bootstrap.blocking.BlockingActionHelper; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; import datadog.trace.util.stacktrace.StackTraceEvent; @@ -931,10 +932,15 @@ private NoopFlow onRequestEnded(RequestContext ctx_, IGSpanInfo spanInfo) { ctx, traceSeg, RESPONSE_HEADERS_ALLOW_LIST, ctx.getResponseHeaders(), false); } // For blocking responses the normal response-header collection is bypassed; write the - // content-type that was determined when the blocking action was raised. + // content-type and content-length that were determined when the blocking action was raised. String blockingContentType = ctx.getBlockingResponseContentType(); if (blockingContentType != null) { traceSeg.setTagTop("http.response.headers.content-type", blockingContentType); + int blockingContentLength = ctx.getBlockingResponseContentLength(); + if (blockingContentLength >= 0) { + traceSeg.setTagTop( + "http.response.headers.content-length", String.valueOf(blockingContentLength)); + } } // If extracted any derivatives - commit them @@ -1299,26 +1305,14 @@ private static void maybeRecordBlockingContentType(AppSecRequestContext ctx, Flo if (bct == BlockingContentType.NONE) { return; // redirect — no response body } - String contentType; - if (bct == BlockingContentType.HTML) { - contentType = "text/html;charset=utf-8"; - } else if (bct == BlockingContentType.JSON) { - contentType = "application/json"; - } else { - // AUTO: pick based on the request Accept header - List acceptValues = ctx.getRequestHeaders().get("accept"); - boolean preferHtml = false; - if (acceptValues != null) { - for (String accept : acceptValues) { - if (accept != null && accept.contains("text/html")) { - preferHtml = true; - break; - } - } - } - contentType = preferHtml ? "text/html;charset=utf-8" : "application/json"; - } - ctx.setBlockingResponseContentType(contentType); + List acceptValues = ctx.getRequestHeaders().get("accept"); + String acceptHeader = + (acceptValues == null || acceptValues.isEmpty()) ? null : acceptValues.get(0); + BlockingActionHelper.TemplateType tt = + BlockingActionHelper.determineTemplateType(bct, acceptHeader); + byte[] template = BlockingActionHelper.getTemplate(tt, rba.getSecurityResponseId()); + ctx.setBlockingResponseContentType(BlockingActionHelper.getContentType(tt)); + ctx.setBlockingResponseContentLength(template.length); } private static Map> parseQueryStringParams( diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy index 2f0539faa16..72da8d3d6d1 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy @@ -19,6 +19,7 @@ import datadog.trace.api.config.GeneralConfig import datadog.trace.api.function.TriConsumer import datadog.trace.api.function.TriFunction import datadog.appsec.api.blocking.BlockingContentType +import datadog.trace.bootstrap.blocking.BlockingActionHelper import datadog.trace.api.gateway.BlockResponseFunction import datadog.trace.api.gateway.Flow import datadog.trace.api.gateway.IGSpanInfo @@ -1661,6 +1662,8 @@ class GatewayBridgeSpecification extends DDSpecification { then: 1 * traceSegment.setTagTop('http.response.headers.content-type', 'application/json') + 1 * traceSegment.setTagTop('http.response.headers.content-length', + String.valueOf(BlockingActionHelper.getTemplate(BlockingActionHelper.TemplateType.JSON, null).length)) } void 'blocking response content-type span tag is written for AUTO bct resolved to text/html'() { @@ -1686,6 +1689,8 @@ class GatewayBridgeSpecification extends DDSpecification { then: 1 * traceSegment.setTagTop('http.response.headers.content-type', 'text/html;charset=utf-8') + 1 * traceSegment.setTagTop('http.response.headers.content-length', + String.valueOf(BlockingActionHelper.getTemplate(BlockingActionHelper.TemplateType.HTML, null).length)) } static toLowerCaseHeaders(final Map> headers) { From 0f276430a35eb0d381939a69e12a63b5f106e80e Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 4 Mar 2026 14:23:18 +0100 Subject: [PATCH 3/4] Fix and more tests --- dd-java-agent/appsec/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dd-java-agent/appsec/build.gradle b/dd-java-agent/appsec/build.gradle index 879560d0c03..c44a4f3311d 100644 --- a/dd-java-agent/appsec/build.gradle +++ b/dd-java-agent/appsec/build.gradle @@ -19,6 +19,8 @@ dependencies { implementation group: 'io.sqreen', name: 'libsqreen', version: '17.3.0' implementation libs.moshi + compileOnly project(':dd-java-agent:agent-bootstrap') + testImplementation project(':dd-java-agent:agent-bootstrap') testImplementation libs.bytebuddy testImplementation project(':remote-config:remote-config-core') testImplementation project(':utils:test-utils') From 57b185119cc4514c896efa4517bc2c0b8a3a216b Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 4 Mar 2026 14:23:18 +0100 Subject: [PATCH 4/4] Fix and more tests --- .../com/datadog/appsec/gateway/AppSecRequestContext.java | 6 +++--- .../main/java/com/datadog/appsec/gateway/GatewayBridge.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java index de997b924e0..860f3119ae4 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java @@ -163,7 +163,7 @@ public class AppSecRequestContext implements DataBundle, Closeable { private volatile boolean wafBlocked; private volatile String blockingResponseContentType; - private volatile int blockingResponseContentLength = -1; + private volatile Integer blockingResponseContentLength; private volatile boolean wafErrors; private volatile boolean wafTruncated; private volatile boolean wafRequestBlockFailure; @@ -247,11 +247,11 @@ public String getBlockingResponseContentType() { return blockingResponseContentType; } - public void setBlockingResponseContentLength(int contentLength) { + public void setBlockingResponseContentLength(Integer contentLength) { this.blockingResponseContentLength = contentLength; } - public int getBlockingResponseContentLength() { + public Integer getBlockingResponseContentLength() { return blockingResponseContentLength; } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java index cee52dfbcce..b4f31543c9b 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java @@ -936,8 +936,8 @@ private NoopFlow onRequestEnded(RequestContext ctx_, IGSpanInfo spanInfo) { String blockingContentType = ctx.getBlockingResponseContentType(); if (blockingContentType != null) { traceSeg.setTagTop("http.response.headers.content-type", blockingContentType); - int blockingContentLength = ctx.getBlockingResponseContentLength(); - if (blockingContentLength >= 0) { + Integer blockingContentLength = ctx.getBlockingResponseContentLength(); + if (blockingContentLength != null) { traceSeg.setTagTop( "http.response.headers.content-length", String.valueOf(blockingContentLength)); }