Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tracer/src/Datadog.Trace/AspNet/TracingHttpModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions tracer/src/Datadog.Trace/Propagators/SpanContextPropagator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpanContextPropagator>();

private readonly ConcurrentDictionary<Key, string?> _defaultTagMappingCache = new();
Expand Down Expand Up @@ -254,6 +264,43 @@ public void AddHeadersToSpanAsTags<THeaders>(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.<name>, regardless of DD_TRACE_HEADER_TAGS or
// AppSec enablement. Empty values are still tagged: per RFC the marker is presence-based.
public void AddSecurityTestingHeadersAsTags<THeaders>(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<string> 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<THeaders, TProcessor>(ref TProcessor processor, THeaders headers, IEnumerable<KeyValuePair<string, string?>> headerToTagMap, string defaultTagPrefix)
where THeaders : IHeadersCollection
where TProcessor : struct, IHeaderTagProcessor
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// <copyright file="SpanContextPropagatorTests_AddSecurityTestingHeadersAsTags.cs" company="Datadog">
// 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.
// </copyright>

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<string>(),
requestedExtractors: Array.Empty<string>(),
propagationExtractFirst: false);

public enum HeaderCollectionType
{
NameValueHeadersCollection,
WebHeadersCollection,
}

public static TheoryData<HeaderCollectionType> 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);
}
}
Loading