Skip to content

Commit 928d44f

Browse files
Extract Spring json body response schemas (#8938)
Signed-off-by: sezen.leblay <[email protected]>
1 parent 0f9b46c commit 928d44f

File tree

4 files changed

+139
-2
lines changed

4 files changed

+139
-2
lines changed

dd-java-agent/instrumentation/spring-webmvc-3.1/src/main/java/datadog/trace/instrumentation/springweb/HttpMessageConverterInstrumentation.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ public void methodAdvice(MethodTransformer transformer) {
7171
.and(takesArgument(1, Class.class))
7272
.and(takesArgument(2, named("org.springframework.http.HttpInputMessage"))),
7373
HttpMessageConverterInstrumentation.class.getName() + "$HttpMessageConverterReadAdvice");
74+
75+
transformer.applyAdvice(
76+
isMethod()
77+
.and(isPublic())
78+
.and(named("write"))
79+
.and(takesArguments(3))
80+
.and(takesArgument(0, Object.class))
81+
.and(takesArgument(1, named("org.springframework.http.MediaType")))
82+
.and(takesArgument(2, named("org.springframework.http.HttpOutputMessage"))),
83+
HttpMessageConverterInstrumentation.class.getName() + "$HttpMessageConverterWriteAdvice");
84+
85+
transformer.applyAdvice(
86+
isMethod()
87+
.and(isPublic())
88+
.and(named("write"))
89+
.and(takesArguments(4))
90+
.and(takesArgument(0, Object.class))
91+
.and(takesArgument(1, Type.class))
92+
.and(takesArgument(2, named("org.springframework.http.MediaType")))
93+
.and(takesArgument(3, named("org.springframework.http.HttpOutputMessage"))),
94+
HttpMessageConverterInstrumentation.class.getName() + "$HttpMessageConverterWriteAdvice");
7495
}
7596

7697
@RequiresRequestContext(RequestContextSlot.APPSEC)
@@ -106,4 +127,37 @@ public static void after(
106127
}
107128
}
108129
}
130+
131+
@RequiresRequestContext(RequestContextSlot.APPSEC)
132+
public static class HttpMessageConverterWriteAdvice {
133+
@Advice.OnMethodEnter(suppress = Throwable.class)
134+
public static void before(
135+
@Advice.Argument(0) final Object obj, @ActiveRequestContext RequestContext reqCtx) {
136+
if (obj == null) {
137+
return;
138+
}
139+
140+
CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC);
141+
BiFunction<RequestContext, Object, Flow<Void>> callback =
142+
cbp.getCallback(EVENTS.responseBody());
143+
if (callback == null) {
144+
return;
145+
}
146+
147+
Flow<Void> flow = callback.apply(reqCtx, obj);
148+
Flow.Action action = flow.getAction();
149+
if (action instanceof Flow.Action.RequestBlockingAction) {
150+
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
151+
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
152+
if (brf != null) {
153+
brf.tryCommitBlockingResponse(
154+
reqCtx.getTraceSegment(),
155+
rba.getStatusCode(),
156+
rba.getBlockingContentType(),
157+
rba.getExtraHeaders());
158+
}
159+
throw new BlockingException("Blocked response (for HttpMessageConverter/write)");
160+
}
161+
}
162+
}
109163
}

dd-java-agent/instrumentation/spring-webmvc-3.1/src/test/groovy/test/boot/SpringBootBasedTest.groovy

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ class SpringBootBasedTest extends HttpServerTest<ConfigurableApplicationContext>
7777
return "boot-context"
7878
}
7979

80+
@Override
81+
boolean testResponseBodyJson() {
82+
return true
83+
}
84+
8085
@Override
8186
String expectedServiceName() {
8287
servletContext
@@ -163,8 +168,7 @@ class SpringBootBasedTest extends HttpServerTest<ConfigurableApplicationContext>
163168

164169
@Override
165170
Map<String, Serializable> expectedExtraServerTags(ServerEndpoint endpoint) {
166-
["servlet.path": endpoint.path, "servlet.context": "/$servletContext"] +
167-
extraServerTags
171+
["servlet.path": endpoint.path, "servlet.context": "/$servletContext"] + extraServerTags
168172
}
169173

170174
@Override

dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/controller/WebController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.nio.file.Paths;
1010
import java.sql.Connection;
1111
import java.sql.DriverManager;
12+
import java.util.Map;
1213
import javax.servlet.http.HttpServletRequest;
1314
import javax.servlet.http.HttpSession;
1415
import org.apache.commons.httpclient.HttpClient;
@@ -239,6 +240,14 @@ public ResponseEntity<String> exceedResponseHeaders() {
239240
return new ResponseEntity<>("Custom headers added", headers, HttpStatus.OK);
240241
}
241242

243+
@PostMapping("/api_security/response")
244+
public ResponseEntity<Map<String, Object>> apiSecurityResponse(
245+
@RequestBody Map<String, Object> body) {
246+
// This endpoint is used to test API security response handling
247+
// It simply returns the body received in the request
248+
return ResponseEntity.ok(body);
249+
}
250+
242251
private void withProcess(final Operation<Process> op) {
243252
Process process = null;
244253
try {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package datadog.smoketest.appsec
2+
3+
import groovy.json.JsonOutput
4+
import groovy.json.JsonSlurper
5+
import okhttp3.MediaType
6+
import okhttp3.Request
7+
import okhttp3.RequestBody
8+
9+
import java.util.zip.GZIPInputStream
10+
11+
class AppSecHttpMessageConverterSmokeTest extends AbstractAppSecServerSmokeTest {
12+
13+
@Override
14+
def logLevel() {
15+
'DEBUG'
16+
}
17+
18+
@Override
19+
ProcessBuilder createProcessBuilder() {
20+
String springBootShadowJar = System.getProperty("datadog.smoketest.appsec.springboot.shadowJar.path")
21+
22+
List<String> command = new ArrayList<>()
23+
command.add(javaPath())
24+
command.addAll(defaultJavaProperties)
25+
command.addAll(defaultAppSecProperties)
26+
command.addAll((String[]) [
27+
"-Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter",
28+
"-jar",
29+
springBootShadowJar,
30+
"--server.port=${httpPort}"
31+
])
32+
ProcessBuilder processBuilder = new ProcessBuilder(command)
33+
processBuilder.directory(new File(buildDirectory))
34+
}
35+
36+
@Override
37+
File createTemporaryFile() {
38+
return new File("${buildDirectory}/tmp/trace-structure-http-converter.out")
39+
}
40+
41+
void 'test response schema extraction'() {
42+
given:
43+
def url = "http://localhost:${httpPort}/api_security/response"
44+
def body = [
45+
"main" : [["key": "id001", "value": 1345.67], ["value": 1567.89, "key": "id002"]],
46+
"nullable": null,
47+
]
48+
def request = new Request.Builder()
49+
.url(url)
50+
.post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body)))
51+
.build()
52+
53+
when:
54+
final response = client.newCall(request).execute()
55+
waitForTraceCount(1)
56+
57+
then:
58+
response.code() == 200
59+
def span = rootSpans.first()
60+
span.meta.containsKey('_dd.appsec.s.res.headers')
61+
span.meta.containsKey('_dd.appsec.s.res.body')
62+
final schema = new JsonSlurper().parse(unzip(span.meta.get('_dd.appsec.s.res.body')))
63+
assert schema == [["main": [[[["key": [8], "value": [16]]]], ["len": 2]], "nullable": [1]]]
64+
}
65+
66+
private static byte[] unzip(final String text) {
67+
final inflaterStream = new GZIPInputStream(new ByteArrayInputStream(text.decodeBase64()))
68+
return inflaterStream.getBytes()
69+
}
70+
}

0 commit comments

Comments
 (0)