diff --git a/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs b/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs index 6dcecd3d50a5..c838d61aed57 100644 --- a/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs +++ b/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs @@ -27,7 +27,8 @@ public GenericWebHostService(IOptions options, IEnumerable startupFilters, IConfiguration configuration, IWebHostEnvironment hostingEnvironment, - HostingMetrics hostingMetrics) + HostingMetrics hostingMetrics, + IOptions urlQueryRedactionOptions) { Options = options.Value; Server = server; @@ -42,6 +43,7 @@ public GenericWebHostService(IOptions options, Configuration = configuration; HostingEnvironment = hostingEnvironment; HostingMetrics = hostingMetrics; + UrlQueryRedactionOptions = urlQueryRedactionOptions.Value; } public GenericWebHostServiceOptions Options { get; } @@ -58,6 +60,7 @@ public GenericWebHostService(IOptions options, public IConfiguration Configuration { get; } public IWebHostEnvironment HostingEnvironment { get; } public HostingMetrics HostingMetrics { get; } + public UrlQueryRedactionOptions UrlQueryRedactionOptions { get; } public async Task StartAsync(CancellationToken cancellationToken) { @@ -156,7 +159,7 @@ static string ExpandPorts(string ports, string scheme) application = ErrorPageBuilder.BuildErrorPageApplication(HostingEnvironment.ContentRootFileProvider, Logger, showDetailedErrors, ex); } - var httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory, HostingEventSource.Log, HostingMetrics); + var httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory, HostingEventSource.Log, HostingMetrics, UrlQueryRedactionOptions); await Server.StartAsync(httpApplication, cancellationToken); HostingEventSource.Log.ServerReady(); diff --git a/src/Hosting/Hosting/src/Internal/HostingApplication.cs b/src/Hosting/Hosting/src/Internal/HostingApplication.cs index 37996905bdf1..d3657e0fd93b 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplication.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplication.cs @@ -32,10 +32,11 @@ public HostingApplication( DistributedContextPropagator propagator, IHttpContextFactory httpContextFactory, HostingEventSource eventSource, - HostingMetrics metrics) + HostingMetrics metrics, + UrlQueryRedactionOptions urlQueryRedactionOptions) { _application = application; - _diagnostics = new HostingApplicationDiagnostics(logger, diagnosticSource, activitySource, propagator, eventSource, metrics); + _diagnostics = new HostingApplicationDiagnostics(logger, diagnosticSource, activitySource, propagator, eventSource, metrics, urlQueryRedactionOptions); if (httpContextFactory is DefaultHttpContextFactory factory) { _defaultHttpContextFactory = factory; diff --git a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs index f648a4eee1a8..7e9d7837127d 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs @@ -34,6 +34,7 @@ internal sealed class HostingApplicationDiagnostics private readonly HostingEventSource _eventSource; private readonly HostingMetrics _metrics; private readonly ILogger _logger; + private readonly UrlQueryRedactionOptions _urlQueryRedactionOptions; // Internal for testing purposes only internal bool SuppressActivityOpenTelemetryData { get; set; } @@ -44,7 +45,8 @@ public HostingApplicationDiagnostics( ActivitySource activitySource, DistributedContextPropagator propagator, HostingEventSource eventSource, - HostingMetrics metrics) + HostingMetrics metrics, + UrlQueryRedactionOptions urlQueryRedactionOptions) { _logger = logger; _diagnosticListener = diagnosticListener; @@ -52,6 +54,7 @@ public HostingApplicationDiagnostics( _propagator = propagator; _eventSource = eventSource; _metrics = metrics; + _urlQueryRedactionOptions = urlQueryRedactionOptions; SuppressActivityOpenTelemetryData = GetSuppressActivityOpenTelemetryData(); } @@ -411,7 +414,7 @@ private void RecordRequestStartMetrics(HttpContext httpContext) hasDiagnosticListener = false; var initializeTags = !SuppressActivityOpenTelemetryData - ? CreateInitializeActivityTags(httpContext) + ? CreateInitializeActivityTags(httpContext, _urlQueryRedactionOptions) : (TagList?)null; var headers = httpContext.Request.Headers; @@ -457,13 +460,12 @@ private void RecordRequestStartMetrics(HttpContext httpContext) return activity; } - private static TagList CreateInitializeActivityTags(HttpContext httpContext) + private static TagList CreateInitializeActivityTags(HttpContext httpContext, UrlQueryRedactionOptions urlQueryRedactionOptions) { // The tags here are set when the activity is created. They can be used in sampling decisions. // Most values in semantic conventions that are present at creation are specified: // https://github.com/open-telemetry/semantic-conventions/blob/27735ccca3746d7bb7fa061dfb73d93bcbae2b6e/docs/http/http-spans.md#L581-L592 // Missing values recommended by the spec are: - // - url.query (need configuration around redaction to do properly) // - http.request.header. // // Note that these tags are added even if Activity.IsAllDataRequested is false, as they may be used in sampling decisions. @@ -497,6 +499,15 @@ private static TagList CreateInitializeActivityTags(HttpContext httpContext) var path = (request.PathBase.HasValue || request.Path.HasValue) ? (request.PathBase + request.Path).ToString() : "/"; creationTags.Add(HostingTelemetryHelpers.AttributeUrlPath, path); + if (urlQueryRedactionOptions.IsEnabled && request.QueryString.HasValue) + { + var redactedQuery = HostingTelemetryHelpers.GetRedactedQueryString(request.QueryString, urlQueryRedactionOptions); + if (redactedQuery != null) + { + creationTags.Add(HostingTelemetryHelpers.AttributeUrlQuery, redactedQuery); + } + } + return creationTags; } diff --git a/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs b/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs index b172a4b74b4d..b10a934880a4 100644 --- a/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs +++ b/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs @@ -4,6 +4,7 @@ using System.Collections.Frozen; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text; using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Hosting; @@ -17,6 +18,7 @@ internal static class HostingTelemetryHelpers public const string AttributeHttpRoute = "http.route"; public const string AttributeUrlScheme = "url.scheme"; public const string AttributeUrlPath = "url.path"; + public const string AttributeUrlQuery = "url.query"; public const string AttributeServerAddress = "server.address"; public const string AttributeServerPort = "server.port"; public const string AttributeUserAgentOriginal = "user_agent.original"; @@ -146,4 +148,80 @@ public static string GetActivityDisplayName(string originalHttpMethod, string? h return string.IsNullOrEmpty(httpRoute) ? namePrefix : $"{namePrefix} {httpRoute}"; } + + /// + /// Redacts sensitive query parameter values from a query string. + /// + /// The query string to redact. + /// The redaction options containing sensitive parameter names and placeholder. + /// The redacted query string, or null if the query string is empty. + public static string? GetRedactedQueryString(QueryString queryString, UrlQueryRedactionOptions options) + { + if (!queryString.HasValue) + { + return null; + } + + var query = queryString.Value; + if (string.IsNullOrEmpty(query)) + { + return null; + } + + var body = query.AsSpan().TrimStart('?'); + + if (body.IsEmpty) + { + return query; + } + + var escapedPlaceholder = Uri.EscapeDataString(options.RedactedPlaceholder); + var sb = new StringBuilder(query.Length); + sb.Append('?'); + + var isFirstSegment = true; + + foreach (var segment in body.Split('&')) + { + var pair = body[segment]; + + if (!isFirstSegment) + { + sb.Append('&'); + } + isFirstSegment = false; + + var rawKey = GetKey(pair); + + string decodedKey; + try + { + decodedKey = Uri.UnescapeDataString(rawKey.ToString()); + } + catch (UriFormatException) + { + sb.Append(pair); + continue; + } + + if (options.SensitiveQueryParameters.Contains(decodedKey)) + { + sb.Append(rawKey); + sb.Append('='); + sb.Append(escapedPlaceholder); + } + else + { + sb.Append(pair); + } + } + + return sb.ToString(); + } + + private static ReadOnlySpan GetKey(ReadOnlySpan pair) + { + var eqIndex = pair.IndexOf('='); + return eqIndex == -1 ? pair : pair.Slice(0, eqIndex); + } } diff --git a/src/Hosting/Hosting/src/Internal/UrlQueryRedactionOptions.cs b/src/Hosting/Hosting/src/Internal/UrlQueryRedactionOptions.cs new file mode 100644 index 000000000000..467cbdd2d4db --- /dev/null +++ b/src/Hosting/Hosting/src/Internal/UrlQueryRedactionOptions.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Hosting; + +/// +/// Options for configuring query string redaction in HTTP telemetry. +/// +public sealed class UrlQueryRedactionOptions +{ + /// + /// Initializes a new instance of . + /// + public UrlQueryRedactionOptions() + { + } + + /// + /// Gets or sets a value indicating whether URL query string redaction is enabled. + /// + /// Defaults to false. Set to true to enable query string redaction. + public bool IsEnabled { get; set; } + + /// + /// Gets the set of query parameter names whose values should be redacted. + /// Parameter name matching is case-insensitive. + /// + /// + /// Default sensitive parameters include: password, pwd, token, api_key, apikey, secret, + /// access_token, refresh_token, credential, key, sig, signature. + /// + public HashSet SensitiveQueryParameters { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "password", + "pwd", + "token", + "api_key", + "apikey", + "secret", + "access_token", + "refresh_token", + "credential", + "key", + "sig", + "signature" + }; + + /// + /// Gets or sets the placeholder text used to replace redacted values. + /// + /// Defaults to "[Redacted]". + public string RedactedPlaceholder { get; set; } = "[Redacted]"; +} diff --git a/src/Hosting/Hosting/src/Internal/WebHost.cs b/src/Hosting/Hosting/src/Internal/WebHost.cs index e0b0857d982f..018f2c6e782c 100644 --- a/src/Hosting/Hosting/src/Internal/WebHost.cs +++ b/src/Hosting/Hosting/src/Internal/WebHost.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Hosting; @@ -143,7 +144,8 @@ public async Task StartAsync(CancellationToken cancellationToken = default) var propagator = _applicationServices.GetRequiredService(); var httpContextFactory = _applicationServices.GetRequiredService(); var hostingMetrics = _applicationServices.GetRequiredService(); - var hostingApp = new HostingApplication(application, _logger, diagnosticSource, activitySource, propagator, httpContextFactory, HostingEventSource.Log, hostingMetrics); + var urlQueryRedactionOptions = _applicationServices.GetRequiredService>().Value; + var hostingApp = new HostingApplication(application, _logger, diagnosticSource, activitySource, propagator, httpContextFactory, HostingEventSource.Log, hostingMetrics, urlQueryRedactionOptions); await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false); _startedServer = true; diff --git a/src/Hosting/Hosting/src/PublicAPI.Unshipped.txt b/src/Hosting/Hosting/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..bb0f13bf79da 100644 --- a/src/Hosting/Hosting/src/PublicAPI.Unshipped.txt +++ b/src/Hosting/Hosting/src/PublicAPI.Unshipped.txt @@ -1 +1,8 @@ #nullable enable +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions.UrlQueryRedactionOptions() -> void +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions.IsEnabled.get -> bool +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions.IsEnabled.set -> void +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions.SensitiveQueryParameters.get -> System.Collections.Generic.HashSet! +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions.RedactedPlaceholder.get -> string! +Microsoft.AspNetCore.Hosting.UrlQueryRedactionOptions.RedactedPlaceholder.set -> void diff --git a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs index 73ad24d8358a..f5af7c3f3944 100644 --- a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs @@ -1182,6 +1182,172 @@ static void AssertKeyValuePair(KeyValuePair pair, string key, T va } } + [Fact] + public void ActivityListeners_QueryStringRedacted_WhenOptionsConfigured() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var redactionOptions = new UrlQueryRedactionOptions { IsEnabled = true }; + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, urlQueryRedactionOptions: redactionOptions); + var tags = new Dictionary(); + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + tags = Activity.Current.TagObjects.ToDictionary(); + } + }; + + ActivitySource.AddActivityListener(listener); + + features.Set(new HttpRequestFeature() + { + Headers = new HeaderDictionary() + { + {"host", "localhost:8080" } + }, + Path = "/search", + QueryString = "?q=laptop&token=secret123&page=2", + Scheme = "http", + Method = "GET", + }); + + hostingApplication.CreateContext(features); + + Assert.True(tags.TryGetValue("url.query", out var queryValue)); + var query = Assert.IsType(queryValue); + Assert.Contains("q=laptop", query); + Assert.Contains("page=2", query); + Assert.Contains("token=%5BRedacted%5D", query); // [Redacted] URL encoded + Assert.DoesNotContain("secret123", query); + } + + [Fact] + public void ActivityListeners_QueryStringNotIncluded_WhenOptionsNotEnabled() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + // IsEnabled defaults to false + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false); + var tags = new Dictionary(); + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + tags = Activity.Current.TagObjects.ToDictionary(); + } + }; + + ActivitySource.AddActivityListener(listener); + + features.Set(new HttpRequestFeature() + { + Headers = new HeaderDictionary() + { + {"host", "localhost:8080" } + }, + Path = "/search", + QueryString = "?q=laptop&token=secret123", + Scheme = "http", + Method = "GET", + }); + + hostingApplication.CreateContext(features); + + Assert.False(tags.ContainsKey("url.query")); + } + + [Fact] + public void ActivityListeners_QueryStringWithCustomPlaceholder() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var redactionOptions = new UrlQueryRedactionOptions + { + IsEnabled = true, + RedactedPlaceholder = "***" + }; + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, urlQueryRedactionOptions: redactionOptions); + var tags = new Dictionary(); + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + tags = Activity.Current.TagObjects.ToDictionary(); + } + }; + + ActivitySource.AddActivityListener(listener); + + features.Set(new HttpRequestFeature() + { + Headers = new HeaderDictionary() + { + {"host", "localhost" } + }, + Path = "/api", + QueryString = "?password=mysecret", + Scheme = "https", + Method = "POST", + }); + + hostingApplication.CreateContext(features); + + Assert.True(tags.TryGetValue("url.query", out var queryValue)); + var query = Assert.IsType(queryValue); + Assert.Contains("password=%2A%2A%2A", query); // *** URL encoded + Assert.DoesNotContain("mysecret", query); + } + + [Fact] + public void ActivityListeners_QueryStringWithCustomSensitiveParameters() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var redactionOptions = new UrlQueryRedactionOptions { IsEnabled = true }; + redactionOptions.SensitiveQueryParameters.Clear(); + redactionOptions.SensitiveQueryParameters.Add("credit_card"); + redactionOptions.SensitiveQueryParameters.Add("ssn"); + + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, urlQueryRedactionOptions: redactionOptions); + var tags = new Dictionary(); + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + tags = Activity.Current.TagObjects.ToDictionary(); + } + }; + + ActivitySource.AddActivityListener(listener); + + features.Set(new HttpRequestFeature() + { + Headers = new HeaderDictionary() + { + {"host", "localhost" } + }, + Path = "/checkout", + QueryString = "?credit_card=1234567890&ssn=123-45-6789&amount=100", + Scheme = "https", + Method = "POST", + }); + + hostingApplication.CreateContext(features); + + Assert.True(tags.TryGetValue("url.query", out var queryValue)); + var query = Assert.IsType(queryValue); + Assert.Contains("amount=100", query); + Assert.Contains("credit_card=%5BRedacted%5D", query); + Assert.Contains("ssn=%5BRedacted%5D", query); + Assert.DoesNotContain("1234567890", query); + Assert.DoesNotContain("123-45-6789", query); + } + [Theory] [InlineData("http", 80)] [InlineData("HTTP", 80)] @@ -1639,8 +1805,10 @@ private static void AssertProperty(object o, string name) private static HostingApplication CreateApplication(out FeatureCollection features, DiagnosticListener diagnosticListener = null, ActivitySource activitySource = null, ILogger logger = null, Action configure = null, HostingEventSource eventSource = null, IMeterFactory meterFactory = null, - bool? suppressActivityOpenTelemetryData = null) + bool? suppressActivityOpenTelemetryData = null, UrlQueryRedactionOptions urlQueryRedactionOptions = null) { + urlQueryRedactionOptions ??= new UrlQueryRedactionOptions(); + var httpContextFactory = new Mock(); features = new FeatureCollection(); @@ -1659,7 +1827,8 @@ private static HostingApplication CreateApplication(out FeatureCollection featur DistributedContextPropagator.CreateDefaultPropagator(), httpContextFactory.Object, eventSource ?? HostingEventSource.Log, - new HostingMetrics(meterFactory ?? new TestMeterFactory())); + new HostingMetrics(meterFactory ?? new TestMeterFactory()), + urlQueryRedactionOptions); if (suppressActivityOpenTelemetryData is { } suppress) { diff --git a/src/Hosting/Hosting/test/HostingApplicationTests.cs b/src/Hosting/Hosting/test/HostingApplicationTests.cs index aea50f7eb2f5..778d17f352cf 100644 --- a/src/Hosting/Hosting/test/HostingApplicationTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationTests.cs @@ -199,7 +199,8 @@ private static HostingApplication CreateApplication(IHttpContextFactory httpCont DistributedContextPropagator.CreateDefaultPropagator(), httpContextFactory, HostingEventSource.Log, - new HostingMetrics(meterFactory ?? new TestMeterFactory())); + new HostingMetrics(meterFactory ?? new TestMeterFactory()), + new UrlQueryRedactionOptions()); return hostingApplication; } diff --git a/src/Hosting/Hosting/test/HostingMetricsTests.cs b/src/Hosting/Hosting/test/HostingMetricsTests.cs index e1f8c0d75def..d4fae8330b09 100644 --- a/src/Hosting/Hosting/test/HostingMetricsTests.cs +++ b/src/Hosting/Hosting/test/HostingMetricsTests.cs @@ -288,7 +288,8 @@ private static HostingApplication CreateApplication(IHttpContextFactory httpCont DistributedContextPropagator.CreateDefaultPropagator(), httpContextFactory, HostingEventSource.Log, - new HostingMetrics(meterFactory ?? new TestMeterFactory())); + new HostingMetrics(meterFactory ?? new TestMeterFactory()), + new UrlQueryRedactionOptions()); return hostingApplication; }