Skip to content

feat(hosting): add url.query redaction for telemetry sensitive parameter#65369

Open
claudiogodoy99 wants to merge 2 commits intodotnet:mainfrom
claudiogodoy99:feature/telemetry-url-query
Open

feat(hosting): add url.query redaction for telemetry sensitive parameter#65369
claudiogodoy99 wants to merge 2 commits intodotnet:mainfrom
claudiogodoy99:feature/telemetry-url-query

Conversation

@claudiogodoy99
Copy link
Contributor

Add configurable query string redaction for OpenTelemetry url.query attribute

  • You've read the Contributor Guide and Code of Conduct.
  • You've included unit or integration tests for your change, where applicable.
  • You've included inline docs for your change, where applicable.
  • There's an open issue for the PR that you are making. If you'd like to propose a new feature or change, please open an issue to discuss the change or find an existing issue.

Summary of the changes (Less than 80 chars)

Summary

This PR introduces configurable redaction of sensitive query string parameters in the url.query OpenTelemetry attribute for HTTP request activities, preventing sensitive data (tokens, passwords, API keys) from being exposed in telemetry.

Changes

New UrlQueryRedactionOptions API:

  • Added public UrlQueryRedactionOptions class with configurable:
    • SensitiveQueryParameters: HashSet of parameter names to redact (default includes: token, api_key, apikey, access_token, password, secret, auth)
    • RedactedPlaceholder: Custom placeholder text (default: [Redacted])

Core Integration:

  • Modified GenericWebHostService to retrieve and pass UrlQueryRedactionOptions from DI container
  • Updated HostingApplication constructor to accept optional UrlQueryRedactionOptions
  • Enhanced HostingApplicationDiagnostics to apply redaction when setting url.query activity tag
  • Added HostingTelemetryHelpers.RedactQueryString() method to perform the actual redaction logic with URL encoding

Behavior:

  • When UrlQueryRedactionOptions is NOT configured: The behavior remains exactly as before — the url.query attribute is not included in telemetry (fully backward compatible, no breaking changes)
  • When UrlQueryRedactionOptions IS configured (via DI), the url.query attribute is included with sensitive values redacted
  • Redacted values are URL-encoded to maintain valid query string format

Testing:

  • Added comprehensive unit tests covering:
    • Default sensitive parameter redaction
    • Custom placeholder configuration
    • Custom sensitive parameter lists
    • Behavior when options not configured (verifies backward compatibility)

Motivation

Address issue: #64850

…ters

Introduce UrlQueryRedactionOptions to redact sensitive query string
values (e.g., tokens, passwords, API keys) from the url.query OpenTelemetry
attribute on HTTP request activities. Configurable sensitive parameter
names and placeholder text. Wired through GenericWebHostService,
HostingApplication, and HostingApplicationDiagnostics. Includes unit tests.
Copilot AI review requested due to automatic review settings February 9, 2026 12:44
@github-actions github-actions bot added the area-hosting Includes Hosting label Feb 9, 2026
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Feb 9, 2026
@dotnet-policy-service
Copy link
Contributor

Thanks for your PR, @@claudiogodoy99. Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an opt-in mechanism for emitting the OpenTelemetry url.query activity tag while redacting configured “sensitive” query-string parameters, to prevent secrets from being captured in HTTP server telemetry.

Changes:

  • Introduces a new public UrlQueryRedactionOptions API to configure sensitive parameter names and a redaction placeholder.
  • Updates Hosting diagnostics/tag initialization to optionally add url.query with redaction applied.
  • Adds a helper (HostingTelemetryHelpers.GetRedactedQueryString) and unit tests validating redaction behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs Adds unit tests validating inclusion/exclusion and redaction behavior for url.query.
src/Hosting/Hosting/src/PublicAPI.Unshipped.txt Declares the new public UrlQueryRedactionOptions API surface.
src/Hosting/Hosting/src/Internal/WebHost.cs Attempts to resolve UrlQueryRedactionOptions from DI and pass to HostingApplication.
src/Hosting/Hosting/src/Internal/UrlQueryRedactionOptions.cs Adds the new public options type and default sensitive parameter list.
src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs Adds helper to redact sensitive query parameter values.
src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs Optionally emits url.query tag during activity creation when options are provided.
src/Hosting/Hosting/src/Internal/HostingApplication.cs Plumbs redaction options into diagnostics.
src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs Attempts to resolve/pass redaction options in generic-host startup path.

Comment on lines 29 to +31
IWebHostEnvironment hostingEnvironment,
HostingMetrics hostingMetrics)
HostingMetrics hostingMetrics,
IOptions<UrlQueryRedactionOptions>? urlQueryRedactionOptions)
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IOptions<UrlQueryRedactionOptions> will typically always be resolvable because the hosting stack registers services.AddOptions() (e.g., WebHostBuilder.cs). As a result, this parameter will be provided even when the app never configured redaction, which defeats the intended opt-in behavior (the feature will look enabled by default). Consider gating enablement on the presence of actual configuration (e.g., IConfigureOptions<UrlQueryRedactionOptions> registrations) or using a dedicated/marker service so you can pass null unless explicitly enabled.

Copilot uses AI. Check for mistakes.
Comment on lines 44 to +46
HostingEnvironment = hostingEnvironment;
HostingMetrics = hostingMetrics;
UrlQueryRedactionOptions = urlQueryRedactionOptions?.Value;
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assigning UrlQueryRedactionOptions = urlQueryRedactionOptions?.Value will produce a non-null default options instance in most apps (since IOptions<T> is available for any T once options are registered). This will cause url.query tagging/redaction to run even when the app didn't opt in. To preserve backward compatibility, only set this property when redaction has been explicitly enabled/configured; otherwise leave it null.

Copilot uses AI. Check for mistakes.
var httpContextFactory = _applicationServices.GetRequiredService<IHttpContextFactory>();
var hostingMetrics = _applicationServices.GetRequiredService<HostingMetrics>();
var hostingApp = new HostingApplication(application, _logger, diagnosticSource, activitySource, propagator, httpContextFactory, HostingEventSource.Log, hostingMetrics);
var urlQueryRedactionOptions = _applicationServices.GetService<IOptions<UrlQueryRedactionOptions>>()?.Value;
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetService<IOptions<UrlQueryRedactionOptions>>()?.Value is not a reliable way to detect whether the app configured redaction. Options are registered via services.AddOptions() during web host setup, so this call will almost always return a default instance, unintentionally enabling url.query tagging for all apps. If the intent is opt-in, gate on the presence of configuration/marker registrations (e.g., GetServices<IConfigureOptions<UrlQueryRedactionOptions>>().Any()) and only then resolve IOptions<UrlQueryRedactionOptions>.

Suggested change
var urlQueryRedactionOptions = _applicationServices.GetService<IOptions<UrlQueryRedactionOptions>>()?.Value;
var hasUrlQueryRedactionConfiguration = _applicationServices.GetServices<IConfigureOptions<UrlQueryRedactionOptions>>().Any();
UrlQueryRedactionOptions? urlQueryRedactionOptions = null;
if (hasUrlQueryRedactionConfiguration)
{
urlQueryRedactionOptions = _applicationServices.GetRequiredService<IOptions<UrlQueryRedactionOptions>>().Value;
}

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +160
/// <returns>The redacted query string, or null if the query string is empty.</returns>
public static string? GetRedactedQueryString(QueryString queryString, UrlQueryRedactionOptions options)
{
if (!queryString.HasValue)
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetRedactedQueryString method declaration is mis-indented compared to the surrounding file, and the formatting doesn’t match the established style in this file. Please align indentation to the rest of the class for consistency.

Copilot uses AI. Check for mistakes.
@claudiogodoy99
Copy link
Contributor Author

claudiogodoy99 commented Feb 9, 2026

Performance Concerns

I've Benchmark some approaches for the new method HostingTelemetryHelpers.RedactQueryString, and that one was the more promising.

Bench Results

| Method             | Categories     | Mean        | Error      | StdDev     | Gen0   | Allocated |
|------------------- |--------------- |------------:|-----------:|-----------:|-------:|----------:|
| ManyParams         | 12_Params      | 439.2046 ns | 20.5173 ns | 13.5709 ns | 0.1125 |     944 B |
|                    |                |             |            |            |        |           |
| AllSensitiveParams | 3_AllSensitive | 201.2194 ns |  9.4309 ns |  4.9325 ns | 0.0715 |     600 B |
|                    |                |             |            |            |        |           |
| NoSensitiveParams  | 3_NonSensitive | 151.1875 ns |  5.1225 ns |  3.3882 ns | 0.0410 |     344 B |
|                    |                |             |            |            |        |           |
| MixedParams        | 5_Mixed        | 279.1219 ns | 14.9330 ns |  9.8773 ns | 0.0858 |     720 B |
|                    |                |             |            |            |        |           |
| EmptyQueryString   | Empty          |   0.9674 ns |  0.0814 ns |  0.0484 ns |      - |         - |
|                    |                |             |            |            |        |           |
| SingleParam        | SingleParam    |  81.2347 ns |  5.7101 ns |  3.7769 ns | 0.0257 |     216 B |
|                    |                |             |            |            |        |           |
| SpecialCharsParams | SpecialChars   | 218.3186 ns |  6.4507 ns |  3.8387 ns | 0.0639 |     536 B |

Bench Code

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using Microsoft.AspNetCore.Http;

BenchmarkRunner.Run<RedactedQueryStringBenchmarks>();

public class UrlQueryRedactionOptions
{
    public HashSet<string> SensitiveQueryParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
    public string RedactedPlaceholder { get; set; } = "***";
}

[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class RedactedQueryStringBenchmarks
{
    private QueryString _emptyQueryString;
    private QueryString _noSensitiveParams;
    private QueryString _allSensitiveParams;
    private QueryString _mixedParams;
    private QueryString _singleParam;
    private QueryString _manyParams;
    private QueryString _specialCharsParams;

    private UrlQueryRedactionOptions _options = null!;

    [GlobalSetup]
    public void Setup()
    {
        _options = new UrlQueryRedactionOptions
        {
            SensitiveQueryParameters = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
            {
                "password", "token", "secret", "apiKey", "credit_card"
            },
            RedactedPlaceholder = "***"
        };

        _emptyQueryString = new QueryString(string.Empty);
        _singleParam = new QueryString("?name=John");
        _noSensitiveParams = new QueryString("?name=John&age=30&city=NYC");
        _allSensitiveParams = new QueryString("?password=abc123&token=xyz789&secret=s3cr3t");
        _mixedParams = new QueryString("?name=John&password=abc123&age=30&token=xyz789&city=NYC");
        _manyParams = new QueryString("?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&password=secret&token=abc&i=9&j=10");
        _specialCharsParams = new QueryString("?name=John+Doe&password=p%40ss+word&city=New+York&token=abc%26def");
    }

    [Benchmark]
    [BenchmarkCategory("Empty")]
    public string? EmptyQueryString() => GetRedactedQueryString(_emptyQueryString, _options);

    [Benchmark]
    [BenchmarkCategory("SingleParam")]
    public string? SingleParam() => GetRedactedQueryString(_singleParam, _options);

    [Benchmark]
    [BenchmarkCategory("3_NonSensitive")]
    public string? NoSensitiveParams() => GetRedactedQueryString(_noSensitiveParams, _options);

    [Benchmark]
    [BenchmarkCategory("3_AllSensitive")]
    public string? AllSensitiveParams() => GetRedactedQueryString(_allSensitiveParams, _options);

    [Benchmark]
    [BenchmarkCategory("5_Mixed")]
    public string? MixedParams() => GetRedactedQueryString(_mixedParams, _options);

    [Benchmark]
    [BenchmarkCategory("12_Params")]
    public string? ManyParams() => GetRedactedQueryString(_manyParams, _options);

    [Benchmark]
    [BenchmarkCategory("SpecialChars")]
    public string? SpecialCharsParams() => GetRedactedQueryString(_specialCharsParams, _options);

    public static string? GetRedactedQueryString(QueryString queryString, UrlQueryRedactionOptions options)
    {
        if (!queryString.HasValue)
        {
            return null;
        }

        var query = queryString.Value;
        if (string.IsNullOrEmpty(query))
        {
            return null;
        }

        // QueryString.Value always starts with '?' (e.g. "?name=John&age=30"),
        // so we strip it here and re-add it to the output below.
        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)
            {
                // Malformed percent-encoding in key — copy segment verbatim
                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<char> GetKey(ReadOnlySpan<char> pair)
    {
        var eqIndex = pair.IndexOf('=');
        return eqIndex == -1 ? pair : pair.Slice(0, eqIndex);
    }
}

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@dotnet-policy-service
Copy link
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Feb 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-hosting Includes Hosting community-contribution Indicates that the PR has been added by a community member pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant