Skip to content

Commit 25e6058

Browse files
committed
Collect client IP tags for AI Guard requests
When DD_AI_GUARD_ENABLED=true, resolve the client IP eagerly during HTTP server request decoration and stash it on the request context. Apply the tags (http.client_ip and network.client.ip) on the local root span only when an ai_guard span is actually created, so non-AI requests of an AI-Guard-enabled service do not get IP tags. APPSEC-62199
1 parent 7fb9855 commit 25e6058

15 files changed

Lines changed: 349 additions & 12 deletions

File tree

dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardInternal.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
import datadog.trace.api.aiguard.AIGuard.ToolCall.Function;
2525
import datadog.trace.api.aiguard.Evaluator;
2626
import datadog.trace.api.aiguard.noop.NoOpEvaluator;
27+
import datadog.trace.api.gateway.RequestContext;
2728
import datadog.trace.api.telemetry.WafMetricCollector;
2829
import datadog.trace.bootstrap.instrumentation.api.AgentScope;
2930
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
3031
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
32+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData;
3133
import datadog.trace.bootstrap.instrumentation.api.Tags;
3234
import java.io.IOException;
3335
import java.lang.annotation.Annotation;
@@ -213,6 +215,32 @@ private boolean isBlockingEnabled(final Options options, final Object isBlocking
213215
return options.block() && "true".equalsIgnoreCase(isBlockingEnabled.toString());
214216
}
215217

218+
/**
219+
* Applies the {@link ClientIpAddressData} captured during HTTP server request decoration to the
220+
* local root span. This is the lazy half of AI Guard client IP collection: {@code
221+
* HttpServerDecorator} resolves the IP eagerly and stashes it on the {@link RequestContext}; we
222+
* consume it here once an {@code ai_guard} span is created, so IP tags are not added to spans of
223+
* non-AI requests in services that have AI Guard enabled.
224+
*/
225+
private static void applyClientIpTags(final AgentSpan localRootSpan) {
226+
final RequestContext requestContext = localRootSpan.getRequestContext();
227+
if (requestContext == null) {
228+
return;
229+
}
230+
final ClientIpAddressData clientIpAddressData = requestContext.getAndResetClientIpAddressData();
231+
if (clientIpAddressData == null) {
232+
return;
233+
}
234+
final String peerIp = clientIpAddressData.getPeerIp();
235+
if (peerIp != null && localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) == null) {
236+
localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, peerIp);
237+
}
238+
final String inferredClientIp = clientIpAddressData.getInferredClientIp();
239+
if (inferredClientIp != null && localRootSpan.getTag(Tags.HTTP_CLIENT_IP) == null) {
240+
localRootSpan.setTag(Tags.HTTP_CLIENT_IP, inferredClientIp);
241+
}
242+
}
243+
216244
@Override
217245
public Evaluation evaluate(final List<Message> messages, final Options options) {
218246
if (messages == null || messages.isEmpty()) {
@@ -229,6 +257,7 @@ public Evaluation evaluate(final List<Message> messages, final Options options)
229257
if (localRootSpan != null) {
230258
localRootSpan.setTag(Tags.AI_GUARD_KEEP, true);
231259
localRootSpan.setTag(EVENT_TAG, true);
260+
applyClientIpTags(localRootSpan);
232261
}
233262
try (final AgentScope scope = tracer.activateSpan(span)) {
234263
final Message last = messages.get(messages.size() - 1);

dd-java-agent/agent-aiguard/src/test/groovy/com/datadog/aiguard/AIGuardInternalTests.groovy

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import com.squareup.moshi.Moshi
77
import datadog.common.version.VersionInfo
88
import datadog.trace.api.Config
99
import datadog.trace.api.aiguard.AIGuard
10+
import datadog.trace.api.gateway.RequestContext
1011
import datadog.trace.api.telemetry.WafMetricCollector
1112
import datadog.trace.bootstrap.instrumentation.api.AgentSpan
1213
import datadog.trace.bootstrap.instrumentation.api.AgentTracer
14+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
1315
import datadog.trace.bootstrap.instrumentation.api.Tags
1416
import datadog.trace.test.util.DDSpecification
1517
import okhttp3.Call
@@ -272,6 +274,68 @@ class AIGuardInternalTests extends DDSpecification {
272274
new AIGuard.Options().block(false) | true | false
273275
}
274276

277+
void 'test evaluate applies captured client ip tags to local root span'() {
278+
given:
279+
final requestContext = Mock(RequestContext)
280+
localRootSpan.getRequestContext() >> requestContext
281+
requestContext.getAndResetClientIpAddressData() >> new ClientIpAddressData('4.4.4.4', '2.3.4.5')
282+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
283+
284+
when:
285+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
286+
287+
then:
288+
1 * localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) >> null
289+
1 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, '4.4.4.4')
290+
1 * localRootSpan.getTag(Tags.HTTP_CLIENT_IP) >> null
291+
1 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, '2.3.4.5')
292+
}
293+
294+
void 'test evaluate does not overwrite existing client ip tags'() {
295+
given:
296+
final requestContext = Mock(RequestContext)
297+
localRootSpan.getRequestContext() >> requestContext
298+
requestContext.getAndResetClientIpAddressData() >> new ClientIpAddressData('4.4.4.4', '2.3.4.5')
299+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
300+
301+
when:
302+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
303+
304+
then:
305+
1 * localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) >> '9.9.9.9'
306+
0 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, _)
307+
1 * localRootSpan.getTag(Tags.HTTP_CLIENT_IP) >> '8.8.8.8'
308+
0 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, _)
309+
}
310+
311+
void 'test evaluate is a noop for client ip tags when no data captured'() {
312+
given:
313+
final requestContext = Mock(RequestContext)
314+
localRootSpan.getRequestContext() >> requestContext
315+
requestContext.getAndResetClientIpAddressData() >> null
316+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
317+
318+
when:
319+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
320+
321+
then:
322+
0 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, _)
323+
0 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, _)
324+
}
325+
326+
void 'test evaluate is a noop for client ip tags when no request context'() {
327+
given:
328+
localRootSpan.getRequestContext() >> null
329+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
330+
331+
when:
332+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
333+
334+
then:
335+
0 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, _)
336+
0 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, _)
337+
}
338+
275339
void 'test evaluate with API errors'() {
276340
given:
277341
final errors = [[status: 400, title: 'Bad request']]

dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
3131
import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext;
3232
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
33+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData;
3334
import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities;
3435
import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes;
3536
import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities;
@@ -248,10 +249,31 @@ public AgentSpan onRequest(
248249
}
249250

250251
AgentSpanContext.Extracted extracted = getExtractedSpanContext(parentContext);
251-
boolean clientIpResolverEnabled =
252+
// Whether to attach IP tags to all requests or not.
253+
// This should be enabled if:
254+
// - DD_TRACE_CLIENT_IP_ENABLED=true, or
255+
// - DD_APPSEC_ENABLED=true (or AppSec enabled at runtime)
256+
// This applies to tags:
257+
// - http.client_ip (IP resolved from proxy tags)
258+
// - network.client.ip (peer IP)
259+
// - tags with proxy header values
260+
// For backwards compatibility, it does not apply to:
261+
// - peer.ipv4
262+
// - peer.ipv6
263+
final boolean shouldTagIps =
252264
config.isClientIpEnabled() || traceClientIpResolverEnabled && APPSEC_ACTIVE;
265+
// Whether to stash IP data for later tagging or not.
266+
// AI Guard requires client IP tags on the local root span when an ai_guard span is created.
267+
// Resolve the IPs eagerly but do not tag the span yet; stash them on the request context so
268+
// AIGuardInternal can apply them lazily, only on requests that actually create an ai_guard
269+
// span.
270+
final boolean shouldStashIps =
271+
!shouldTagIps && traceClientIpResolverEnabled && config.isAiGuardEnabled();
272+
// Whether to resolve client IP based on proxy headers or no.
273+
final boolean shouldResolveIp = shouldTagIps || shouldStashIps;
274+
253275
if (extracted != null) {
254-
if (clientIpResolverEnabled) {
276+
if (shouldTagIps) {
255277
String forwarded = extracted.getForwarded();
256278
if (forwarded != null) {
257279
span.setTag(Tags.HTTP_FORWARDED, forwarded);
@@ -332,7 +354,7 @@ public AgentSpan onRequest(
332354
}
333355

334356
String inferredAddressStr = null;
335-
if (clientIpResolverEnabled && extracted != null) {
357+
if (shouldResolveIp && extracted != null) {
336358
InetAddress inferredAddress = ClientIpAddressResolver.resolve(extracted, span);
337359
// the peer address should be used if:
338360
// 1. the headers yield nothing, regardless of whether it is public or not
@@ -349,9 +371,11 @@ public AgentSpan onRequest(
349371
}
350372
if (inferredAddress != null) {
351373
inferredAddressStr = inferredAddress.getHostAddress();
352-
span.setTag(Tags.HTTP_CLIENT_IP, inferredAddressStr);
374+
if (shouldTagIps) {
375+
span.setTag(Tags.HTTP_CLIENT_IP, inferredAddressStr);
376+
}
353377
}
354-
} else if (clientIpResolverEnabled && span.getLocalRootSpan() != span) {
378+
} else if (shouldTagIps && span.getLocalRootSpan() != span) {
355379
// in this case extracted == null
356380
// If there is no extracted we can't do anything but use the peer addr.
357381
// Additionally, extracted == null arises on subspans for which the resolution
@@ -370,10 +394,16 @@ public AgentSpan onRequest(
370394
} else {
371395
span.setTag(Tags.PEER_HOST_IPV4, peerIp);
372396
}
373-
if (clientIpResolverEnabled) {
397+
if (shouldTagIps) {
374398
span.setTag(Tags.NETWORK_CLIENT_IP, peerIp);
375399
}
376400
}
401+
if (shouldStashIps && (peerIp != null || inferredAddressStr != null)) {
402+
RequestContext requestContext = span.getRequestContext();
403+
if (requestContext != null) {
404+
requestContext.setClientIpAddressData(new ClientIpAddressData(peerIp, inferredAddressStr));
405+
}
406+
}
377407
setPeerPort(span, peerPort);
378408
Flow<Void> flow = callIGCallbackAddressAndPort(span, peerIp, peerPort, inferredAddressStr);
379409
if (flow.getAction() instanceof RequestBlockingAction) {

dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecoratorTest.groovy

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpan
1515
import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext
1616
import datadog.trace.bootstrap.instrumentation.api.AgentTracer
1717
import datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI
18+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
1819
import datadog.trace.bootstrap.instrumentation.api.ContextVisitors
1920
import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities
2021
import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities
@@ -300,6 +301,61 @@ class HttpServerDecoratorTest extends ServerDecoratorTest {
300301
0 * this.span.setTag(Tags.NETWORK_CLIENT_IP, _)
301302
}
302303

304+
void 'enabling ai guard captures client ip data without tagging the span during request decoration'() {
305+
setup:
306+
injectSysConfig('dd.ai_guard.enabled', 'true')
307+
ActiveSubsystems.APPSEC_ACTIVE = false
308+
309+
def extracted = Mock(AgentSpanContext.Extracted)
310+
def context = AgentSpan.fromSpanContext(extracted)
311+
def requestContext = Mock(RequestContext)
312+
def decorator = newDecorator()
313+
314+
when:
315+
decorator.onRequest(this.span, [peerIp: '4.4.4.4'], null, context)
316+
317+
then:
318+
_ * extracted.getXForwardedFor() >> '2.3.4.5'
319+
_ * extracted.getXClusterClientIp() >> null
320+
_ * extracted.getXRealIp() >> null
321+
_ * extracted.getXClientIp() >> null
322+
_ * extracted.getCustomIpHeader() >> null
323+
_ * extracted.getTrueClientIp() >> null
324+
_ * extracted.getFastlyClientIp() >> null
325+
_ * extracted.getCfConnectingIp() >> null
326+
_ * extracted.getCfConnectingIpv6() >> null
327+
_ * this.span.getRequestContext() >> requestContext
328+
1 * requestContext.setClientIpAddressData({ ClientIpAddressData data ->
329+
data.peerIp == '4.4.4.4' && data.inferredClientIp == '2.3.4.5'
330+
})
331+
0 * this.span.setTag(Tags.HTTP_CLIENT_IP, _)
332+
0 * this.span.setTag(Tags.NETWORK_CLIENT_IP, _)
333+
0 * this.span.setTag(Tags.HTTP_FORWARDED_IP, _)
334+
}
335+
336+
void 'enabling ai guard does not override client_ip_without_appsec tagging behavior'() {
337+
setup:
338+
injectSysConfig('dd.ai_guard.enabled', 'true')
339+
injectSysConfig('dd.trace.client-ip.enabled', 'true')
340+
ActiveSubsystems.APPSEC_ACTIVE = false
341+
342+
def extracted = Mock(AgentSpanContext.Extracted)
343+
def context = AgentSpan.fromSpanContext(extracted)
344+
def requestContext = Mock(RequestContext)
345+
def decorator = newDecorator()
346+
347+
when:
348+
decorator.onRequest(this.span, [peerIp: '4.4.4.4'], null, context)
349+
350+
then:
351+
_ * this.span.getRequestContext() >> requestContext
352+
2 * extracted.getXForwardedFor() >> '2.3.4.5'
353+
1 * this.span.setTag(Tags.HTTP_CLIENT_IP, '2.3.4.5')
354+
1 * this.span.setTag(Tags.NETWORK_CLIENT_IP, '4.4.4.4')
355+
// ai guard capture is skipped because tags were already set
356+
0 * requestContext.setClientIpAddressData(_)
357+
}
358+
303359
void 'disabling appsec but enabling client_ip_without_appsec enables header collection and ip address resolution'() {
304360
setup:
305361
injectSysConfig('dd.trace.client-ip.enabled', 'true')

dd-java-agent/appsec/src/jmh/java/datadog/appsec/benchmark/AppSecBenchmark.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import datadog.trace.api.gateway.RequestContextSlot;
1818
import datadog.trace.api.gateway.SubscriptionService;
1919
import datadog.trace.api.internal.TraceSegment;
20+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData;
2021
import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter;
2122
import datadog.trace.bootstrap.instrumentation.api.URIDefaultDataAdapter;
2223
import java.io.IOException;
@@ -254,6 +255,14 @@ public <T> T getOrCreateMetaStructTop(String key, Function<String, T> defaultVal
254255
return null;
255256
}
256257

258+
@Override
259+
public void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {}
260+
261+
@Override
262+
public ClientIpAddressData getAndResetClientIpAddressData() {
263+
return null;
264+
}
265+
257266
@Override
258267
public void close() throws IOException {}
259268
}

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import datadog.trace.api.gateway.RequestContextSlot
2828
import datadog.trace.api.gateway.SubscriptionService
2929
import datadog.trace.api.http.StoredBodySupplier
3030
import datadog.trace.api.internal.TraceSegment
31+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
3132
import datadog.trace.api.telemetry.LoginEvent
3233
import datadog.trace.api.telemetry.RuleType
3334
import datadog.trace.api.telemetry.WafMetricCollector
@@ -79,6 +80,14 @@ class GatewayBridgeSpecification extends DDSpecification {
7980
return null
8081
}
8182

83+
@Override
84+
void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {}
85+
86+
@Override
87+
ClientIpAddressData getAndResetClientIpAddressData() {
88+
return null
89+
}
90+
8291
@Override
8392
void close() throws IOException {}
8493
}
@@ -1253,6 +1262,14 @@ class GatewayBridgeSpecification extends DDSpecification {
12531262
return null
12541263
}
12551264

1265+
@Override
1266+
void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {}
1267+
1268+
@Override
1269+
ClientIpAddressData getAndResetClientIpAddressData() {
1270+
return null
1271+
}
1272+
12561273
@Override
12571274
void close() throws IOException {}
12581275
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import datadog.trace.api.gateway.BlockResponseFunction
44
import datadog.trace.api.gateway.RequestContext
55
import datadog.trace.api.gateway.RequestContextSlot
66
import datadog.trace.api.internal.TraceSegment
7+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
78

89
import java.util.function.Function
910

@@ -56,6 +57,16 @@ class ValidatingRequestContextDecorator implements RequestContext {
5657
return delegate.getOrCreateMetaStructTop(key, defaultValue)
5758
}
5859

60+
@Override
61+
void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {
62+
delegate.setClientIpAddressData(clientIpAddressData)
63+
}
64+
65+
@Override
66+
ClientIpAddressData getAndResetClientIpAddressData() {
67+
return delegate.getAndResetClientIpAddressData()
68+
}
69+
5970
@Override
6071
void close() throws IOException {
6172
delegate.close()

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
public class SpringbootApplication {
1111

1212
public static void main(final String[] args) {
13-
try {
14-
activateAppSec();
15-
} catch (Exception e) {
16-
System.out.println("Could not activate appSec: " + e.getMessage());
13+
if (!Boolean.getBoolean("smoketest.skipAppSecActivation")) {
14+
try {
15+
activateAppSec();
16+
} catch (Exception e) {
17+
System.out.println("Could not activate appSec: " + e.getMessage());
18+
}
19+
} else {
20+
System.out.println("AppSec activation skipped");
1721
}
1822

1923
SpringApplication.run(SpringbootApplication.class, args);

0 commit comments

Comments
 (0)