Skip to content

Commit 89b226b

Browse files
Analyze okhttp client redirections as separated request (#10252)
feat(aap): analyze okhttp client redirections as separated request
1 parent 6985cfa commit 89b226b

11 files changed

Lines changed: 336 additions & 93 deletions

File tree

dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ public void increaseRaspTimeouts() {
275275
public boolean sampleHttpClientRequest(final long id) {
276276
httpClientRequestCount.incrementAndGet();
277277
synchronized (sampledHttpClientRequests) {
278+
if (sampledHttpClientRequests.contains(id)) {
279+
return true;
280+
}
278281
if (sampledHttpClientRequests.size()
279282
< Config.get().getApiSecurityMaxDownstreamRequestBodyAnalysis()) {
280283
sampledHttpClientRequests.add(id);

dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpClientTest.groovy

Lines changed: 88 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ import datadog.trace.bootstrap.instrumentation.api.URIUtils
2222
import datadog.trace.core.DDSpan
2323
import datadog.trace.core.datastreams.StatsGroup
2424
import datadog.trace.test.util.Flaky
25-
import groovy.json.JsonOutput
26-
import groovy.json.JsonSlurper
2725
import spock.lang.AutoCleanup
2826
import spock.lang.IgnoreIf
2927
import spock.lang.Requires
@@ -42,7 +40,6 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.HTTP_CLIENT_TA
4240
import static datadog.trace.api.config.TracerConfig.HEADER_TAGS
4341
import static datadog.trace.api.config.TracerConfig.REQUEST_HEADER_TAGS
4442
import static datadog.trace.api.config.TracerConfig.RESPONSE_HEADER_TAGS
45-
import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.activeSpan
4643
import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.get
4744

4845
abstract class HttpClientTest extends VersionedNamingTestBase {
@@ -69,7 +66,7 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
6966
}
7067
prefix("redirect") {
7168
handleDistributedRequest()
72-
redirect(server.address.resolve("/success").toURL().toString())
69+
redirect(server.address.resolve(request.getHeader('Location') ?: "/success").toURL().toString())
7370
}
7471
prefix("another-redirect") {
7572
handleDistributedRequest()
@@ -95,23 +92,21 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
9592
handleDistributedRequest()
9693
String msg = "Hello."
9794
response.status(200)
98-
.addHeader('x-datadog-test-response-header', 'baz')
99-
.send(msg)
95+
.addHeader('x-datadog-test-response-header', 'baz')
96+
.send(msg)
10097
}
10198
prefix("/timeout") {
10299
Thread.sleep(10_000)
103100
throw new IllegalStateException("Should never happen")
104101
}
105102
prefix("/json") {
106-
if (request.getContentType() != 'application/json') {
107-
response.status(400).send('Bad content type')
108-
} else {
109-
response
110-
.status(200)
111-
.addHeader('Content-Type', 'application/json')
112-
.addHeader('X-AppSec-Test', 'true')
113-
.sendWithType('application/json', request.body)
114-
}
103+
// echo if input is json
104+
final responseBody = request.getContentType() == 'application/json' ? request.body : '{"goodbye": "world!"}'.bytes
105+
response
106+
.status(200)
107+
.addHeader('Content-Type', 'application/json')
108+
.addHeader('X-AppSec-Test', 'true')
109+
.sendWithType('application/json', responseBody)
115110
}
116111
}
117112
}
@@ -146,19 +141,19 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
146141
def setupSpec() {
147142
List<Proxy> proxyList = Collections.singletonList(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxy.port)))
148143
proxySelector = new ProxySelector() {
149-
@Override
150-
List<Proxy> select(URI uri) {
151-
if (uri.fragment == "proxy") {
152-
return proxyList
153-
}
154-
return getDefault().select(uri)
144+
@Override
145+
List<Proxy> select(URI uri) {
146+
if (uri.fragment == "proxy") {
147+
return proxyList
155148
}
149+
return getDefault().select(uri)
150+
}
156151

157-
@Override
158-
void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
159-
getDefault().connectFailed(uri, sa, ioe)
160-
}
152+
@Override
153+
void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
154+
getDefault().connectFailed(uri, sa, ioe)
161155
}
156+
}
162157

163158
// Register the Instrumentation Gateway callbacks
164159
def ss = get().getSubscriptionService(RequestContextSlot.APPSEC)
@@ -910,16 +905,9 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
910905
void 'test appsec client request analysis'() {
911906
given:
912907
final url = server.address.resolve(endpoint)
913-
final tags = [
914-
'downstream.request.url': url.toString(),
915-
'downstream.request.method': method,
916-
'downstream.request.body': body,
917-
'downstream.response.status': 200,
918-
'downstream.response.body': body,
919-
]
920908

921909
when:
922-
final status = runUnderAppSecTrace {
910+
def (ctx, status) = runUnderAppSecTrace {
923911
doRequest(method, url, ['Content-Type': contentType] + headers, body) {
924912
InputStream response ->
925913
assert response.text == body
@@ -928,25 +916,66 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
928916

929917
then:
930918
status == 200
931-
TEST_WRITER.waitForTraces(1)
932-
final span = TEST_WRITER.get(0).find {
933-
it.spanType == 'http'
934-
}
935-
tags.each {
936-
assert span.getTag(it.key) == it.value
919+
final request = ctx.requests.first()
920+
request.method == method
921+
request.url == url.toString()
922+
request.body.bytes == body.bytes
923+
headers.each {
924+
assert request.headers[it.key] == [it.value]
937925
}
938-
final requestHeaders = new JsonSlurper().parseText(span.getTag("downstream.request.headers") as String) as Map<String, List<String>>
939-
final responseHeaders = new JsonSlurper().parseText(span.getTag("downstream.response.headers") as String) as Map<String, List<String>>
926+
927+
final response = ctx.responses.first()
928+
response.status == 200
929+
response.body.bytes == body.bytes
940930
headers.each {
941-
assert requestHeaders[it.key] == [it.value]
942-
assert responseHeaders[it.key] == [it.value]
931+
assert response.headers[it.key] == [it.value]
943932
}
944933

945934
where:
946935
endpoint | method | contentType | headers | body
947936
'/json' | 'POST' | 'application/json' | ['X-AppSec-Test': 'true'] | '{"hello": "world!" }'
948937
}
949938

939+
@IgnoreIf({
940+
!instance.testAppSecClientRedirect()
941+
})
942+
void 'test appsec client redirect analysis'() {
943+
given:
944+
final url = server.address.resolve(endpoint)
945+
946+
when:
947+
def (ctx, status) = runUnderAppSecTrace {
948+
doRequest(method, url, ['Content-Type': contentType] + headers, requestBody)
949+
}
950+
951+
then:
952+
status == 200
953+
954+
def (initialRequest, redirectRequest) = ctx.requests
955+
initialRequest.method == method
956+
initialRequest.url == url.toString()
957+
initialRequest.body.bytes == requestBody.bytes
958+
headers.each {
959+
assert initialRequest.headers[it.key] == [it.value]
960+
}
961+
962+
redirectRequest.method == 'GET'
963+
redirectRequest.url.toString().endsWith('/json')
964+
redirectRequest.body == null
965+
966+
def (redirectResponse, finalResponse) = ctx.responses
967+
redirectResponse.status == 302
968+
redirectResponse.body == null
969+
redirectResponse.headers['Location'][0].endsWith('/json')
970+
971+
finalResponse.status == 200
972+
finalResponse.body.bytes == responseBody.bytes
973+
974+
where:
975+
endpoint | method | contentType | headers | requestBody | responseBody
976+
'/redirect' | 'POST' | 'application/json' | ['X-AppSec-Test': 'true', 'Location': '/json'] | '{"hello": "world!" }' | '{"goodbye": "world!"}'
977+
}
978+
950979
// parent span must be cast otherwise it breaks debugging classloading (junit loads it early)
951980
void clientSpan(
952981
TraceAssert trace,
@@ -1070,11 +1099,16 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
10701099
false
10711100
}
10721101

1073-
protected <E> E runUnderAppSecTrace(Closure<E> cl) {
1074-
final ddctx = new TagContext().withRequestContextDataAppSec(new IGCallbacks.Context())
1102+
boolean testAppSecClientRedirect() {
1103+
false
1104+
}
1105+
1106+
protected <E> Tuple2<IGCallbacks.Context, E> runUnderAppSecTrace(Closure<E> cl) {
1107+
final ctx = new IGCallbacks.Context()
1108+
final ddctx = new TagContext().withRequestContextDataAppSec(ctx)
10751109
final span = TEST_TRACER.startSpan("test", "test-appsec-span", ddctx)
10761110
try {
1077-
return AgentTracer.activateSpan(span).withCloseable(cl)
1111+
return Tuple.tuple(ctx, AgentTracer.activateSpan(span).withCloseable(cl))
10781112
} finally {
10791113
span.finish()
10801114
}
@@ -1084,6 +1118,8 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
10841118

10851119
static class Context {
10861120
boolean hasAppSecData
1121+
List<HttpClientRequest> requests = []
1122+
List<HttpClientResponse> responses = []
10871123
}
10881124

10891125
final BiFunction<RequestContext, Long, Flow<Boolean>> httpClientBodySamplingCb = {
@@ -1093,16 +1129,11 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
10931129

10941130
final BiFunction<RequestContext, HttpClientRequest, Flow<Void>> httpClientRequestCb = {
10951131
RequestContext rqCtxt, HttpClientRequest req ->
1096-
if (req.headers?.containsKey('X-AppSec-Test')) {
1097-
final context = rqCtxt.getData(RequestContextSlot.APPSEC) as Context
1098-
if (context != null) {
1099-
context.hasAppSecData = true
1100-
activeSpan()
1101-
.setTag('downstream.request.url', req.url)
1102-
.setTag('downstream.request.method', req.method)
1103-
.setTag('downstream.request.headers', JsonOutput.toJson(req.headers))
1104-
.setTag('downstream.request.body', req.body?.text)
1105-
}
1132+
final context = rqCtxt.getData(RequestContextSlot.APPSEC) as Context
1133+
final boolean isAppSec = req.headers?.containsKey('X-AppSec-Test')
1134+
if (isAppSec || context?.hasAppSecData) {
1135+
context.hasAppSecData = true
1136+
context.requests.add(req)
11061137
}
11071138
Flow.ResultFlow.empty()
11081139
} as BiFunction<RequestContext, HttpClientRequest, Flow<Void>>
@@ -1111,10 +1142,7 @@ abstract class HttpClientTest extends VersionedNamingTestBase {
11111142
RequestContext rqCtxt, HttpClientResponse res ->
11121143
final context = rqCtxt.getData(RequestContextSlot.APPSEC) as Context
11131144
if (context?.hasAppSecData) {
1114-
activeSpan()
1115-
.setTag('downstream.response.status', res.status)
1116-
.setTag('downstream.response.headers', JsonOutput.toJson(res.headers))
1117-
.setTag('downstream.response.body', res.body?.text)
1145+
context.responses.add(res)
11181146
}
11191147
Flow.ResultFlow.empty()
11201148
} as BiFunction<RequestContext, HttpClientResponse, Flow<Void>>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package datadog.trace.instrumentation.okhttp2;
2+
3+
import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
4+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
5+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
6+
7+
import com.google.auto.service.AutoService;
8+
import com.squareup.okhttp.Request;
9+
import com.squareup.okhttp.Response;
10+
import datadog.trace.agent.tooling.Instrumenter;
11+
import datadog.trace.agent.tooling.InstrumenterModule;
12+
import datadog.trace.api.gateway.RequestContext;
13+
import datadog.trace.api.gateway.RequestContextSlot;
14+
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
15+
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
16+
import net.bytebuddy.asm.Advice;
17+
18+
@AutoService(InstrumenterModule.class)
19+
public class AppSecHttpEngineInstrumentation extends InstrumenterModule.AppSec
20+
implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice {
21+
22+
public AppSecHttpEngineInstrumentation() {
23+
super("okhttp", "okhttp-2");
24+
}
25+
26+
@Override
27+
public String instrumentedType() {
28+
return "com.squareup.okhttp.internal.http.HttpEngine";
29+
}
30+
31+
@Override
32+
public String[] helperClassNames() {
33+
return new String[] {
34+
packageName + ".AppSecInterceptor",
35+
};
36+
}
37+
38+
@Override
39+
public void methodAdvice(final MethodTransformer transformer) {
40+
transformer.applyAdvice(
41+
isMethod().and(named("sendRequest")).and(takesArguments(0)),
42+
AppSecHttpEngineInstrumentation.class.getName() + "$SendRequestAdvice");
43+
}
44+
45+
public static class SendRequestAdvice {
46+
@Advice.OnMethodEnter
47+
public static void onSendRequest(
48+
@Advice.FieldValue("priorResponse") final Response priorResponse,
49+
@Advice.FieldValue("userRequest") final Request userRequest) {
50+
// only redirects
51+
if (priorResponse == null || priorResponse.code() < 300 || priorResponse.code() >= 400) {
52+
return;
53+
}
54+
final AgentSpan span = AgentTracer.activeSpan();
55+
final RequestContext ctx = span.getRequestContext();
56+
if (ctx == null) {
57+
return;
58+
}
59+
if (ctx.getData(RequestContextSlot.APPSEC) == null) {
60+
return;
61+
}
62+
63+
// increment the number of downstream requests but do not include request/response body
64+
AppSecInterceptor.sampleRequest(ctx, span.getSpanId());
65+
AppSecInterceptor.onResponse(span, false, priorResponse);
66+
AppSecInterceptor.onRequest(span, false, userRequest.urlString(), userRequest);
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)