From 1f0ab0c865fdd27554715712f3692fcb9f5ad1aa Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Fri, 22 May 2026 16:28:56 +0200 Subject: [PATCH] [AppSec] Collect Datadog security-testing headers on entry spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APPSEC-65483 Tag `x-datadog-endpoint-scan` and `x-datadog-security-test` HTTP request headers as `http.request.headers.` on every HTTP server entry span (and on the inferred-proxy span when one is created), unconditionally — independent of `DD_TRACE_HEADER_TAGS` and AppSec enablement. These markers let the API endpoint reducer distinguish Datadog scan/test traffic from real user traffic and keep it out of the API inventory. Wired into: ASP.NET MVC, ASP.NET Web API 2, OWIN/IIS classic, ASP.NET Core, WCF over HTTP. Markers are not propagated downstream. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Datadog.Trace/AspNet/TracingHttpModule.cs | 5 + .../AspNet/AspNetMvcIntegration.cs | 2 + .../AspNet/AspNetWebApi2Integration.cs | 1 + .../AutoInstrumentation/Wcf/WcfCommon.cs | 1 + .../AspNetCoreHttpRequestHandler.cs | 10 + .../Propagators/SpanContextPropagator.cs | 47 +++++ ...orTests_AddSecurityTestingHeadersAsTags.cs | 176 ++++++++++++++++++ 7 files changed, 242 insertions(+) create mode 100644 tracer/test/Datadog.Trace.Tests/SpanContextPropagatorTests_AddSecurityTestingHeadersAsTags.cs diff --git a/tracer/src/Datadog.Trace/AspNet/TracingHttpModule.cs b/tracer/src/Datadog.Trace/AspNet/TracingHttpModule.cs index 7cff22d137c6..9dd5623e15df 100644 --- a/tracer/src/Datadog.Trace/AspNet/TracingHttpModule.cs +++ b/tracer/src/Datadog.Trace/AspNet/TracingHttpModule.cs @@ -192,6 +192,11 @@ private void OnBeginRequest(object sender, EventArgs eventArgs) : null; scope.Span.DecorateWebServerSpan(resourceName: resourceName, httpMethod, host, url, userAgent, tags); tracer.TracerManager.SpanContextPropagator.AddHeadersToSpanAsTags(scope.Span, headers, tracer.CurrentTraceSettings.Settings.HeaderTags, defaultTagPrefix: SpanContextPropagator.HttpRequestHeadersTagPrefix); + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(scope.Span, headers); + if (inferredProxyScope?.Span is { } proxySpan) + { + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(proxySpan, headers); + } tracer.TracerManager.SpanContextPropagator.AddBaggageToSpanAsTags(scope.Span, extractedContext.Baggage, tracer.Settings.BaggageTagKeys); diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetMvcIntegration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetMvcIntegration.cs index ec0ae406c0e8..db47dcbe7ddf 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetMvcIntegration.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetMvcIntegration.cs @@ -152,6 +152,7 @@ internal static Scope CreateScope(ControllerContextStruct controllerContext) SharedItems.PushScope(HttpContext.Current, HttpProxyContextKey, proxyContext.Value.Scope); // Update the context to use the proxy span's context extractedContext = proxyContext.Value.Context; + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(proxyContext.Value.Scope.Span, headers.Value); } } } @@ -181,6 +182,7 @@ internal static Scope CreateScope(ControllerContextStruct controllerContext) if (headers is not null) { tracer.TracerManager.SpanContextPropagator.AddHeadersToSpanAsTags(span, headers.Value, tracer.CurrentTraceSettings.Settings.HeaderTags, SpanContextPropagator.HttpRequestHeadersTagPrefix); + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(span, headers.Value); } tracer.TracerManager.SpanContextPropagator.AddBaggageToSpanAsTags(span, extractedContext.Baggage, tracer.Settings.BaggageTagKeys); diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetWebApi2Integration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetWebApi2Integration.cs index 249b20303522..b36285183514 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetWebApi2Integration.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/AspNetWebApi2Integration.cs @@ -102,6 +102,7 @@ internal static Scope CreateScope(IHttpControllerContext controllerContext, out if (headersCollection is not null) { tracer.TracerManager.SpanContextPropagator.AddHeadersToSpanAsTags(scope.Span, headersCollection.Value, tracer.CurrentTraceSettings.Settings.HeaderTags, SpanContextPropagator.HttpRequestHeadersTagPrefix, request.Headers.UserAgent.ToString()); + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(scope.Span, headersCollection.Value); } tracer.TracerManager.SpanContextPropagator.AddBaggageToSpanAsTags(scope.Span, extractedContext.Baggage, tracer.Settings.BaggageTagKeys); diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Wcf/WcfCommon.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Wcf/WcfCommon.cs index 8987aa81c1ae..bd893faa1ea5 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Wcf/WcfCommon.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Wcf/WcfCommon.cs @@ -196,6 +196,7 @@ internal static class WcfCommon { var headerTagsProcessor = new SpanContextPropagator.SpanTagHeaderTagProcessor(span); tracer.TracerManager.SpanContextPropagator.ExtractHeaderTags(ref headerTagsProcessor, headers.Value, tracer.CurrentTraceSettings.Settings.HeaderTags!, SpanContextPropagator.HttpRequestHeadersTagPrefix); + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(span, headers.Value); } tags.SetAnalyticsSampleRate(IntegrationId, tracer.CurrentTraceSettings.Settings, enabledWithGlobalSetting: true); diff --git a/tracer/src/Datadog.Trace/PlatformHelpers/AspNetCoreHttpRequestHandler.cs b/tracer/src/Datadog.Trace/PlatformHelpers/AspNetCoreHttpRequestHandler.cs index 4cd518cc3c90..ce70a8a7a917 100644 --- a/tracer/src/Datadog.Trace/PlatformHelpers/AspNetCoreHttpRequestHandler.cs +++ b/tracer/src/Datadog.Trace/PlatformHelpers/AspNetCoreHttpRequestHandler.cs @@ -162,6 +162,16 @@ private Scope StartAspNetCorePipelineScope(Tracer tracer, Security security, Ias AddHeaderTagsToSpan(scope.Span, request, tracer, headerTagsInternal); } + if (request.Headers is { } requestHeaders) + { + var headersAdapter = new HeadersCollectionAdapter(requestHeaders); + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(scope.Span, headersAdapter); + if (proxyContext?.Scope?.Span is { } proxySpan) + { + tracer.TracerManager.SpanContextPropagator.AddSecurityTestingHeadersAsTags(proxySpan, headersAdapter); + } + } + tracer.TracerManager.SpanContextPropagator.AddBaggageToSpanAsTags(scope.Span, extractedContext.Baggage, tracer.Settings.BaggageTagKeys); var originalPath = request.PathBase.HasValue ? request.PathBase.Add(request.Path) : request.Path; diff --git a/tracer/src/Datadog.Trace/Propagators/SpanContextPropagator.cs b/tracer/src/Datadog.Trace/Propagators/SpanContextPropagator.cs index ddeee10f3a24..e4cc0f0b8d0b 100644 --- a/tracer/src/Datadog.Trace/Propagators/SpanContextPropagator.cs +++ b/tracer/src/Datadog.Trace/Propagators/SpanContextPropagator.cs @@ -21,6 +21,16 @@ internal sealed class SpanContextPropagator internal const string HttpRequestHeadersTagPrefix = "http.request.headers"; internal const string HttpResponseHeadersTagPrefix = "http.response.headers"; + // Datadog scan/test markers, tagged unconditionally on HTTP server entry spans so + // the API endpoint reducer can distinguish scan/test traffic from real user traffic + // and keep it out of the API inventory. Tag names are precomputed to avoid string + // concatenation per request. + private static readonly (string Header, string Tag)[] SecurityTestingHeaders = + [ + ("x-datadog-endpoint-scan", HttpRequestHeadersTagPrefix + ".x-datadog-endpoint-scan"), + ("x-datadog-security-test", HttpRequestHeadersTagPrefix + ".x-datadog-security-test"), + ]; + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); private readonly ConcurrentDictionary _defaultTagMappingCache = new(); @@ -254,6 +264,43 @@ public void AddHeadersToSpanAsTags(ISpan span, THeaders headers, IEnum ExtractHeaderTags(ref processor, headers, headerToTagMap, defaultTagPrefix, useragent); } + // Tags the Datadog scan/test markers (x-datadog-endpoint-scan, x-datadog-security-test) + // on the span as http.request.headers., regardless of DD_TRACE_HEADER_TAGS or + // AppSec enablement. Empty values are still tagged: per RFC the marker is presence-based. + public void AddSecurityTestingHeadersAsTags(ISpan span, THeaders headers) + where THeaders : IHeadersCollection + { + foreach (var (headerName, tagName) in SecurityTestingHeaders) + { + var values = headers.GetValues(headerName); + if (values is null) + { + continue; + } + + // string[] fast path avoids the IEnumerator heap allocation on the + // .NET Framework legacy carriers (NameValueHeadersCollection, WebHeadersCollection) + // which back `GetValues` with a string[]. ASP.NET Core's StringValues goes through + // the slow path below — that's a single boxed enumerator per request, unchanged + // from how the existing AddHeadersToSpanAsTags handles it. + if (values is string[] array) + { + if (array.Length > 0) + { + span.SetTag(tagName, array[0]); + } + + continue; + } + + foreach (var value in values) + { + span.SetTag(tagName, value); + break; + } + } + } + internal void ExtractHeaderTags(ref TProcessor processor, THeaders headers, IEnumerable> headerToTagMap, string defaultTagPrefix) where THeaders : IHeadersCollection where TProcessor : struct, IHeaderTagProcessor diff --git a/tracer/test/Datadog.Trace.Tests/SpanContextPropagatorTests_AddSecurityTestingHeadersAsTags.cs b/tracer/test/Datadog.Trace.Tests/SpanContextPropagatorTests_AddSecurityTestingHeadersAsTags.cs new file mode 100644 index 000000000000..2e090f662cec --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/SpanContextPropagatorTests_AddSecurityTestingHeadersAsTags.cs @@ -0,0 +1,176 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +using System; +using System.Collections.Specialized; +using System.Net; +using Datadog.Trace.ExtensionMethods; +using Datadog.Trace.Headers; +using Datadog.Trace.Propagators; +using Datadog.Trace.TestHelpers; +using Datadog.Trace.Util; +using FluentAssertions; +using Xunit; +#if !NETFRAMEWORK +using Microsoft.AspNetCore.Http; +#endif + +namespace Datadog.Trace.Tests +{ + [Collection(nameof(WebRequestCollection))] + public class SpanContextPropagatorTests_AddSecurityTestingHeadersAsTags + { + private const string EndpointScanHeader = "x-datadog-endpoint-scan"; + private const string SecurityTestHeader = "x-datadog-security-test"; + private const string EndpointScanTag = "http.request.headers.x-datadog-endpoint-scan"; + private const string SecurityTestTag = "http.request.headers.x-datadog-security-test"; + + // AddSecurityTestingHeadersAsTags doesn't read instance state — construct a bare + // propagator with no injectors/extractors rather than spinning up a full Tracer. + private static readonly SpanContextPropagator Propagator = SpanContextPropagatorFactory.GetSpanContextPropagator( + requestedInjectors: Array.Empty(), + requestedExtractors: Array.Empty(), + propagationExtractFirst: false); + + public enum HeaderCollectionType + { + NameValueHeadersCollection, + WebHeadersCollection, + } + + public static TheoryData GetHeaderCollections() + => new() { HeaderCollectionType.NameValueHeadersCollection, HeaderCollectionType.WebHeadersCollection, }; + + [Theory] + [MemberData(nameof(GetHeaderCollections))] + public void TagsBothMarkersWhenPresentAndIgnoresUnrelatedHeaders(HeaderCollectionType headersType) + { + var headers = GetHeadersCollection(headersType); + headers.Add(EndpointScanHeader, "scan-uuid-1"); + headers.Add(SecurityTestHeader, "test-uuid-2"); + headers.Add("x-other-header", "ignored"); + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, headers); + + span.GetTag(EndpointScanTag).Should().Be("scan-uuid-1"); + span.GetTag(SecurityTestTag).Should().Be("test-uuid-2"); + span.GetTag("http.request.headers.x-other-header").Should().BeNull(); + } + + [Theory] + [MemberData(nameof(GetHeaderCollections))] + public void DoesNotTagWhenHeadersAreAbsent(HeaderCollectionType headersType) + { + var headers = GetHeadersCollection(headersType); + headers.Add("content-type", "application/json"); + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, headers); + + span.GetTag(EndpointScanTag).Should().BeNull(); + span.GetTag(SecurityTestTag).Should().BeNull(); + } + + [Theory] + [MemberData(nameof(GetHeaderCollections))] + public void TagsHeadersUnconditionallyWithoutAnyHeaderTagsConfig(HeaderCollectionType headersType) + { + // The RFC contract: collection happens regardless of DD_TRACE_HEADER_TAGS. The helper + // is independent of the HeaderTags dictionary — this test confirms that calling it + // with no header-tag configuration still produces the markers. + var headers = GetHeadersCollection(headersType); + headers.Add(EndpointScanHeader, "scan-uuid"); + headers.Add(SecurityTestHeader, "test-uuid"); + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, headers); + + span.GetTag(EndpointScanTag).Should().Be("scan-uuid"); + span.GetTag(SecurityTestTag).Should().Be("test-uuid"); + } + + [Theory] + [MemberData(nameof(GetHeaderCollections))] + public void OnlyOneOfTheTwoHeadersIsCollectedIfOnlyOnePresent(HeaderCollectionType headersType) + { + var headers = GetHeadersCollection(headersType); + headers.Add(EndpointScanHeader, "scan-uuid"); + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, headers); + + span.GetTag(EndpointScanTag).Should().Be("scan-uuid"); + span.GetTag(SecurityTestTag).Should().BeNull(); + } + + [Theory] + [MemberData(nameof(GetHeaderCollections))] + public void TagsHeadersEvenWhenValueIsEmptyString(HeaderCollectionType headersType) + { + // RFC: collect unconditionally — presence of the header with an empty value + // is still a valid signal. + var headers = GetHeadersCollection(headersType); + headers.Add(EndpointScanHeader, string.Empty); + headers.Add(SecurityTestHeader, "ok"); + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, headers); + + span.GetTag(EndpointScanTag).Should().Be(string.Empty); + span.GetTag(SecurityTestTag).Should().Be("ok"); + } + + [Theory] + [MemberData(nameof(GetHeaderCollections))] + public void MatchesHeaderNamesCaseInsensitively(HeaderCollectionType headersType) + { + // ASP.NET Core's IHeaderDictionary and System.Net's WebHeaderCollection are + // case-insensitive by contract. Asserting it here locks the contract in regardless + // of the underlying carrier the entry-span integration happens to use. + var headers = GetHeadersCollection(headersType); + headers.Add("X-Datadog-Endpoint-Scan", "scan-uuid"); + headers.Add("X-DATADOG-SECURITY-TEST", "test-uuid"); + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, headers); + + span.GetTag(EndpointScanTag).Should().Be("scan-uuid"); + span.GetTag(SecurityTestTag).Should().Be("test-uuid"); + } + +#if !NETFRAMEWORK + [Fact] + public void WorksWithAspNetCoreHeaderDictionary() + { + // The highest-traffic production carrier is HeadersCollectionAdapter, which wraps + // IHeaderDictionary. Cover it directly here, including the mixed-case lookup that + // IHeaderDictionary supports out of the box. + var dictionary = new HeaderDictionary + { + ["X-Datadog-Endpoint-Scan"] = "scan-uuid", + ["x-datadog-security-test"] = "test-uuid", + }; + + var span = CreateSpan(); + Propagator.AddSecurityTestingHeadersAsTags(span, new HeadersCollectionAdapter(dictionary)); + + span.GetTag(EndpointScanTag).Should().Be("scan-uuid"); + span.GetTag(SecurityTestTag).Should().Be("test-uuid"); + } +#endif + + private static IHeadersCollection GetHeadersCollection(HeaderCollectionType type) + => type switch + { + HeaderCollectionType.WebHeadersCollection => WebRequest.CreateHttp("http://localhost").Headers.Wrap(), + HeaderCollectionType.NameValueHeadersCollection => new NameValueCollection().Wrap(), + _ => throw new Exception("Unknown header collection type " + type), + }; + + private static Span CreateSpan() + => new(new SpanContext(traceId: 42, RandomIdGenerator.Shared.NextSpanId()), DateTimeOffset.UtcNow); + } +}