Skip to content

fix: filter sensitive URL query parameters before they reach Sentry - #5891

Open
jamescrosswell wants to merge 5 commits into
mainfrom
ai/data-collection-scrub-query-ce0979
Open

jamescrosswell wants to merge 5 commits into
mainfrom
ai/data-collection-scrub-query-ce0979

Conversation

@jamescrosswell

@jamescrosswell jamescrosswell commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Query strings reached Sentry in clear text, so ?access_token=... or ?session_id=... was sent as-is. This applies the sensitive-terms denylist from SensitiveDataFilter to query parameter names and replaces matching values with [Filtered]. Keys are kept.

QueryStringFilter works on the raw string, with no Uri or QueryHelpers round-trip, so q=a%20b%26c survives byte-for-byte as spec 0.11.0 requires. Names are percent-decoded for matching only.

Closes #5424

Changelog Entry

  • Values of sensitive URL query parameters, such as token or session_id, are now replaced with [Filtered] in events, spans and breadcrumbs, regardless of SendDefaultPii. Outbound HTTP and GraphQL span descriptions no longer include the query string or fragment.

Where it applies

Path Change
ASP.NET Core request.query_string Filtered
ASP.NET request.query_string Filtered, now read from RawUrl because HttpRequest.QueryString.ToString() re-encodes
ASP.NET request.url No longer carries the query, matching ASP.NET Core
Failed-request events (HTTP and GraphQL) request.query_string and request.url Filtered, with or without SendDefaultPii
Outbound HTTP and GraphQL spans Query and fragment removed from the description
Outbound HTTP and GraphQL breadcrumbs url filtered
OpenTelemetry spans url.full, http.url, http.target and url.query filtered; query removed from descriptions and transaction names

Worth checking in review

  • "Unparseable" needed a definition. The spec gives none for query strings. Here the whole query string becomes [Filtered] when a parameter name has a malformed percent escape, or is empty with a value (=abc). In both cases the name can't be matched reliably: %u0074oken is token to System.Web, for example. A malformed escape in a value (q=100%) is left alone.
  • Valueless keys and empty values. ?token stays as it is because there is no value to replace. ?token= becomes token=[Filtered], matching SensitiveDataFilter.FilterValue.
  • Breadcrumbs go beyond the issue's table. I filtered url because it is the same full URL as the url.full row. It still comes from Uri.ToString(), as before, so %20 shows decoded there; %26 and other reserved escapes stay encoded.
  • Span descriptions change. Per the issue's table, outbound spans no longer show the query at all.

Not covered

  • DataCollectionOptions.UrlQueryParams isn't read yet, so this is the default deny-list behaviour only. Off and allow-list modes need the resolver work.
  • The SDK's own HTTP spans don't emit url.full or url.query. Adding them would be new data, so nothing changes there.
  • URLs inside log messages, such as ASP.NET Core's "Request starting" line, which can become breadcrumbs, are free text and aren't filtered.
  • Sentry.OpenTelemetry.Exporter sends OTLP directly, so SentrySpanProcessor filtering doesn't apply to it.
  • HTTP instrumentation in the native Android and Cocoa SDKs.

🤖 Generated with Claude Code

Applies the sensitive-terms denylist to query parameter names, replacing
matching values with [Filtered] and keeping encoded parameters exactly as
they appear in the URL. Span descriptions no longer include the query.

Closes #5424

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.95%. Comparing base (ccf4820) to head (d4215bf).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
src/Sentry.Maui/Internal/MauiEventsBinder.cs 0.00% 0 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5891      +/-   ##
==========================================
+ Coverage   76.77%   76.95%   +0.17%     
==========================================
  Files         532      534       +2     
  Lines       19826    19897      +71     
  Branches     3909     3937      +28     
==========================================
+ Hits        15222    15312      +90     
+ Misses       3718     3706      -12     
+ Partials      886      879       -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Moves the null check out of the failed-request handlers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamescrosswell
jamescrosswell marked this pull request as ready for review November 10, 2026 02:52
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 10, 2026

@ric-oliv ric-oliv left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Blazor WebAssembly integration still sends page URLs with their query string untouched. BlazorWasmOptionsSetup.ToRelativePath feeds request.url and the from and to of navigation breadcrumbs, so a page like /reset-password?token=abc reaches Sentry as is. That file isn't in this diff, so I can't suggest it inline.

What do you think about running it through the new filter? It's one line plus using Sentry.Internal.DataCollection;:

private string ToRelativePath(string url)
{
    var relative = _navigationManager.ToBaseRelativePath(url);
    return "/" + QueryStringFilter.FilterUrl(relative);
}

And a test for BlazorWasmOptionsSetupTests:

[Fact]
public void Navigation_SensitiveQueryParameter_FiltersRequestUrlAndBreadcrumb()
{
    // Arrange
    _sut.Configure(new SentryBlazorOptions());

    // Act
    _navigationManager.NavigateTo("/reset?token=abc123&page=5");

    // Assert
    var crumb = _scope.Breadcrumbs.Should().ContainSingle().Subject;
    crumb.Data.Should().ContainKey("to").WhoseValue.Should().Be("/reset?token=[Filtered]&page=5");
    _scope.Request.Url.Should().Be("/reset?token=[Filtered]&page=5");
}

MAUI Shell navigation has the same gap. MauiEventsBinder puts Location.ToString() straight into the from and to of the Shell navigation breadcrumbs, so after GoToAsync("details?token=abc") the Shell.Navigating breadcrumb carries the token as is. Wrapping the four from and to values in QueryStringFilter.FilterUrl(...), with the same using, fixes it.

If you'd rather do these in a follow-up, could you add Blazor WebAssembly and MAUI Shell to "Not covered" and narrow the changelog entry? Right now it promises filtering in events and breadcrumbs in general.

[Theory]
[InlineData("%zz=abc")]
[InlineData("to%ken=abc")]
[InlineData("%74oken%=abc")]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nothing pins the i + 2 >= name.Length boundary in HasMalformedEscape yet. If it turns into >, a name ending in %7 throws IndexOutOfRangeException and the whole suite stays green. One more row would catch it:

Suggested change
[InlineData("%74oken%=abc")]
[InlineData("%74oken%=abc")]
[InlineData("%74oken%7=abc")]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added the %74oken%7=abc row in 12c4360.

🤖 Addressed by Claude Code

Comment on lines +16 to +33
var parameters = queryString[prefix.Length..].Split('&');
for (var i = 0; i < parameters.Length; i++)
{
var parameter = parameters[i];
var separator = parameter.IndexOf('=');
var name = separator < 0 ? parameter : parameter[..separator];
if (HasMalformedEscape(name) || (name.Length == 0 && parameter.Length > 1))
{
return SensitiveDataFilter.FilteredValue;
}

if (separator >= 0 && SensitiveDataFilter.IsSensitiveKey(SensitiveDataFilter.DecodeKeyForMatching(name)))
{
parameters[i] = $"{name}={SensitiveDataFilter.FilteredValue}";
}
}

return prefix + string.Join("&", parameters);

@ric-oliv ric-oliv Nov 10, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This one's small and optional. When no name matches, Filter still joins and concatenates, and FilterUrl rebuilds the URL. The HTTP breadcrumb pays that on every outbound request with a query string. For https://api.example.com/v1/users?page=2&limit=50&sort=name that's 640 extra bytes per request. Returning the input when nothing matched, here and in FilterUrl below, brings it down to 336. A URL with ten plain parameters goes from 1,824 to 1,160 bytes.

Suggested change
var parameters = queryString[prefix.Length..].Split('&');
for (var i = 0; i < parameters.Length; i++)
{
var parameter = parameters[i];
var separator = parameter.IndexOf('=');
var name = separator < 0 ? parameter : parameter[..separator];
if (HasMalformedEscape(name) || (name.Length == 0 && parameter.Length > 1))
{
return SensitiveDataFilter.FilteredValue;
}
if (separator >= 0 && SensitiveDataFilter.IsSensitiveKey(SensitiveDataFilter.DecodeKeyForMatching(name)))
{
parameters[i] = $"{name}={SensitiveDataFilter.FilteredValue}";
}
}
return prefix + string.Join("&", parameters);
var parameters = queryString[prefix.Length..].Split('&');
var filtered = false;
for (var i = 0; i < parameters.Length; i++)
{
var parameter = parameters[i];
var separator = parameter.IndexOf('=');
var name = separator < 0 ? parameter : parameter[..separator];
if (HasMalformedEscape(name) || (name.Length == 0 && parameter.Length > 1))
{
return SensitiveDataFilter.FilteredValue;
}
if (separator >= 0 && SensitiveDataFilter.IsSensitiveKey(SensitiveDataFilter.DecodeKeyForMatching(name)))
{
parameters[i] = $"{name}={SensitiveDataFilter.FilteredValue}";
filtered = true;
}
}
return filtered ? prefix + string.Join("&", parameters) : queryString;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Filter now returns the input instance when no parameter matched (12c4360). The nothing-to-filter test asserts BeSameAs.

🤖 Addressed by Claude Code

Comment on lines +51 to +52
var queryEnd = fragmentStart < 0 ? url.Length : fragmentStart;
return $"{url[..(queryStart + 1)]}{Filter(url[(queryStart + 1)..queryEnd])}{url[queryEnd..]}";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

With the change above, FilterUrl can hand back the original URL too:

Suggested change
var queryEnd = fragmentStart < 0 ? url.Length : fragmentStart;
return $"{url[..(queryStart + 1)]}{Filter(url[(queryStart + 1)..queryEnd])}{url[queryEnd..]}";
var queryEnd = fragmentStart < 0 ? url.Length : fragmentStart;
var query = url[(queryStart + 1)..queryEnd];
var filteredQuery = Filter(query);
return ReferenceEquals(filteredQuery, query) ? url : $"{url[..(queryStart + 1)]}{filteredQuery}{url[queryEnd..]}";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

FilterUrl now returns the original URL when the query came back unchanged (12c4360), with a test row for a URL whose query has nothing to filter.

🤖 Addressed by Claude Code

…on URLs

Also return the input unchanged when no parameter needs filtering, and pin
the HasMalformedEscape boundary with a "%7" test row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ric-oliv ric-oliv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the quick turnaround! The MAUI Shell part of my review is still open. I added it to the review body in an edit after posting, so it's easy to miss. MauiEventsBinder puts Location.ToString() straight into the from and to of the Shell navigation breadcrumbs, so after GoToAsync("details?token=abc") the Shell.Navigating breadcrumb carries the token as is. Wrapping the four from and to values in QueryStringFilter.FilterUrl(...) fixes it. If you'd rather leave it for a follow-up, could you add MAUI Shell to "Not covered"?

…crumbs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FilteredHttpRequestUrl appends Filter's result to the path, so a bare
"[Filtered]" lost the query marker, e.g. "https://example.com/api[Filtered]".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DataCollection phase 0: scrub sensitive URL query parameters

2 participants