Repository navigation
fix: filter sensitive URL query parameters before they reach Sentry - #5891
jamescrosswell wants to merge 5 commits into
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
Moves the null check out of the failed-request handlers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
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")] |
There was a problem hiding this comment.
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:
| [InlineData("%74oken%=abc")] | |
| [InlineData("%74oken%=abc")] | |
| [InlineData("%74oken%7=abc")] |
There was a problem hiding this comment.
Added the %74oken%7=abc row in 12c4360.
🤖 Addressed by Claude Code
| 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); |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
Filter now returns the input instance when no parameter matched (12c4360). The nothing-to-filter test asserts BeSameAs.
🤖 Addressed by Claude Code
| var queryEnd = fragmentStart < 0 ? url.Length : fragmentStart; | ||
| return $"{url[..(queryStart + 1)]}{Filter(url[(queryStart + 1)..queryEnd])}{url[queryEnd..]}"; |
There was a problem hiding this comment.
With the change above, FilterUrl can hand back the original URL too:
| 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..]}"; |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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>
Query strings reached Sentry in clear text, so
?access_token=...or?session_id=...was sent as-is. This applies the sensitive-terms denylist fromSensitiveDataFilterto query parameter names and replaces matching values with[Filtered]. Keys are kept.QueryStringFilterworks on the raw string, with noUriorQueryHelpersround-trip, soq=a%20b%26csurvives byte-for-byte as spec 0.11.0 requires. Names are percent-decoded for matching only.Closes #5424
Changelog Entry
tokenorsession_id, are now replaced with[Filtered]in events, spans and breadcrumbs, regardless ofSendDefaultPii. Outbound HTTP and GraphQL span descriptions no longer include the query string or fragment.Where it applies
request.query_stringrequest.query_stringRawUrlbecauseHttpRequest.QueryString.ToString()re-encodesrequest.urlrequest.query_stringandrequest.urlSendDefaultPiiurlfilteredurl.full,http.url,http.targetandurl.queryfiltered; query removed from descriptions and transaction namesWorth checking in review
[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:%u0074okenistokento System.Web, for example. A malformed escape in a value (q=100%) is left alone.?tokenstays as it is because there is no value to replace.?token=becomestoken=[Filtered], matchingSensitiveDataFilter.FilterValue.urlbecause it is the same full URL as theurl.fullrow. It still comes fromUri.ToString(), as before, so%20shows decoded there;%26and other reserved escapes stay encoded.Not covered
DataCollectionOptions.UrlQueryParamsisn't read yet, so this is the default deny-list behaviour only. Off and allow-list modes need the resolver work.url.fullorurl.query. Adding them would be new data, so nothing changes there.Sentry.OpenTelemetry.Exportersends OTLP directly, soSentrySpanProcessorfiltering doesn't apply to it.🤖 Generated with Claude Code