Skip to content

Commit 476f1c9

Browse files
Remove authority from URLs sent to Sentry (#2365)
* Added a UrlPiiSanitizer that can be used to strip auth details from URLs * Urls in the Message or Data of Breadcrumbs are now sanitized * Transaction Description sanitized when the transaction is captured by the SentryClient * Moved URL redaction logic to SentryClient, just before envelope creation * Renamed Sanitize to Redact in unit tests and comments for consistency * Transaction.Spans are now redacted before they are sent to Sentry - Included some comments on why we don't redact User and Request on the Transaction and SentryEvent. - Moved FluentAssertions.Execution to global usings in Directory.Build.props - Added end to end test to ensure events captured on the SentryClient get redacted, if necessary * Update sentry-cocoa * Update src/Sentry/SentryClient.cs Co-authored-by: Matt Johnson-Pint <[email protected]> * Update CHANGELOG.md Co-authored-by: Matt Johnson-Pint <[email protected]> * Update src/Sentry/Internal/PiiExtensions.cs Co-authored-by: Matt Johnson-Pint <[email protected]> * Update src/Sentry/Request.cs Co-authored-by: Matt Johnson-Pint <[email protected]> * Fixed nullability errors after changing signature on RedactUrl extension * Made Regex instances private static readonly * Removed unecessary null forgiving operator from Breadcrumb.Data * Strip UserInfo from the Request.Url before capturing Failed Requests * Undo submodule update * Replaced bespoke code with Uri.GetComponents * Fixed missing arg in test --------- Co-authored-by: Matt Johnson-Pint <[email protected]>
1 parent 02a269a commit 476f1c9

17 files changed

+480
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Features
66

7+
- Remove authority from URLs sent to Sentry ([#2365](https://github.com/getsentry/sentry-dotnet/pull/2365))
78
- Add `Hint` support ([#2351](https://github.com/getsentry/sentry-dotnet/pull/2351))
89
- Currently, this allows you to manipulate attachments in the various "before" event delegates.
910
- Hints can also be used in event and transaction processors by implementing `ISentryEventProcessorWithHint` or `ISentryTransactionProcessorWithHint`, instead of `ISentryEventProcessor` or `ISentryTransactionProcessor`.

src/Sentry/Breadcrumb.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Sentry.Extensibility;
2+
using Sentry.Internal;
23
using Sentry.Internal.Extensions;
34

45
namespace Sentry;
@@ -9,6 +10,12 @@ namespace Sentry;
910
[DebuggerDisplay("Message: {" + nameof(Message) + "}, Type: {" + nameof(Type) + "}")]
1011
public sealed class Breadcrumb : IJsonSerializable
1112
{
13+
private readonly IReadOnlyDictionary<string, string>? _data;
14+
private readonly string? _message;
15+
16+
private bool _sendDefaultPii = true;
17+
internal void Redact() => _sendDefaultPii = false;
18+
1219
/// <summary>
1320
/// A timestamp representing when the breadcrumb occurred.
1421
/// </summary>
@@ -21,7 +28,11 @@ public sealed class Breadcrumb : IJsonSerializable
2128
/// If a message is provided, it’s rendered as text and the whitespace is preserved.
2229
/// Very long text might be abbreviated in the UI.
2330
/// </summary>
24-
public string? Message { get; }
31+
public string? Message
32+
{
33+
get => _sendDefaultPii ? _message : _message?.RedactUrl();
34+
private init => _message = value;
35+
}
2536

2637
/// <summary>
2738
/// The type of breadcrumb.
@@ -39,7 +50,17 @@ public sealed class Breadcrumb : IJsonSerializable
3950
/// Contains a sub-object whose contents depend on the breadcrumb type.
4051
/// Additional parameters that are unsupported by the type are rendered as a key/value table.
4152
/// </remarks>
42-
public IReadOnlyDictionary<string, string>? Data { get; }
53+
public IReadOnlyDictionary<string, string>? Data
54+
{
55+
get => _sendDefaultPii
56+
? _data
57+
: _data?.ToDictionary(
58+
x => x.Key,
59+
x => x.Value.RedactUrl()
60+
)
61+
;
62+
private init => _data = value;
63+
}
4364

4465
/// <summary>
4566
/// Dotted strings that indicate what the crumb is or where it comes from.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
namespace Sentry.Internal;
2+
3+
/// <summary>
4+
/// Extensions to help redact data that might contain Personally Identifiable Information (PII) before sending it to
5+
/// Sentry.
6+
/// </summary>
7+
internal static class PiiExtensions
8+
{
9+
internal const string RedactedText = "[Filtered]";
10+
private static readonly Regex AuthRegex = new (@"(?i)\b(https?://.*@.*)\b", RegexOptions.Compiled);
11+
private static readonly Regex UserInfoMatcher = new (@"^(?i)(https?://)(.*@)(.*)$", RegexOptions.Compiled);
12+
13+
/// <summary>
14+
/// Searches for URLs in text data and redacts any PII data from these, as required.
15+
/// </summary>
16+
/// <param name="data">The data to be searched</param>
17+
/// <returns>
18+
/// The data, if no PII data is present or a copy of the data with PII data redacted otherwise
19+
/// </returns>
20+
public static string RedactUrl(this string data)
21+
{
22+
// If the data is empty then we don't need to redact anything
23+
if (string.IsNullOrWhiteSpace(data))
24+
{
25+
return data;
26+
}
27+
28+
// The pattern @"(?i)\b(https?://.*@.*)\b" uses the \b word boundary anchors to ensure that the match occurs at
29+
// a word boundary. This allows the URL to be matched even if it is part of a larger text. The (?i) flag ensures
30+
// case-insensitive matching for "https" or "http".
31+
var result = AuthRegex.Replace(data, match =>
32+
{
33+
var matchedUrl = match.Groups[1].Value;
34+
return RedactAuth(matchedUrl);
35+
});
36+
37+
return result;
38+
}
39+
40+
private static string RedactAuth(string data)
41+
{
42+
// ^ matches the start of the string. (?i)(https?://) gives a case-insensitive matching of the protocol.
43+
// (.*@) matches the username and password (authentication information). (.*)$ matches the rest of the URL.
44+
var match = UserInfoMatcher.Match(data);
45+
if (match is not { Success: true, Groups.Count: 4 })
46+
{
47+
return data;
48+
}
49+
var userInfoString = match.Groups[2].Value;
50+
var replacementString = userInfoString.Contains(":") ? "[Filtered]:[Filtered]@" : "[Filtered]@";
51+
return match.Groups[1].Value + replacementString + match.Groups[3].Value;
52+
}
53+
}

src/Sentry/SentryClient.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ public void CaptureTransaction(Transaction transaction, Hint? hint)
147147
return;
148148
}
149149

150+
if (!_options.SendDefaultPii)
151+
{
152+
processedTransaction.Redact();
153+
}
154+
150155
CaptureEnvelope(Envelope.FromTransaction(processedTransaction));
151156
}
152157

@@ -276,6 +281,11 @@ private SentryId DoSendEvent(SentryEvent @event, Hint? hint, Scope? scope)
276281
return SentryId.Empty;
277282
}
278283

284+
if (!_options.SendDefaultPii)
285+
{
286+
processedEvent.Redact();
287+
}
288+
279289
var attachments = hint.Attachments.ToList();
280290
var envelope = Envelope.FromEvent(processedEvent, _options.DiagnosticLogger, attachments, scope.SessionUpdate);
281291
return CaptureEnvelope(envelope) ? processedEvent.EventId : SentryId.Empty;

src/Sentry/SentryEvent.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ public void SetTag(string key, string value) =>
242242
public void UnsetTag(string key) =>
243243
(_tags ??= new Dictionary<string, string>()).Remove(key);
244244

245+
internal void Redact()
246+
{
247+
foreach (var breadcrumb in Breadcrumbs)
248+
{
249+
breadcrumb.Redact();
250+
}
251+
}
252+
245253
/// <inheritdoc />
246254
public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
247255
{

src/Sentry/SentryFailedRequestHandler.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,24 +79,24 @@ public void HandleResponse(HttpResponseMessage response)
7979

8080
var sentryRequest = new Request
8181
{
82-
Url = uri?.AbsoluteUri,
8382
QueryString = uri?.Query,
8483
Method = response.RequestMessage.Method.Method,
8584
};
8685

87-
if (_options.SendDefaultPii)
88-
{
89-
sentryRequest.Cookies = response.RequestMessage.Headers.GetCookies();
90-
sentryRequest.AddHeaders(response.RequestMessage.Headers);
91-
}
92-
9386
var responseContext = new Response {
9487
StatusCode = (short)response.StatusCode,
9588
BodySize = bodySize
9689
};
9790

98-
if (_options.SendDefaultPii)
91+
if (!_options.SendDefaultPii)
9992
{
93+
sentryRequest.Url = uri?.GetComponents(UriComponents.HttpRequestUrl, UriFormat.Unescaped);
94+
}
95+
else
96+
{
97+
sentryRequest.Url = uri?.AbsoluteUri;
98+
sentryRequest.Cookies = response.RequestMessage.Headers.GetCookies();
99+
sentryRequest.AddHeaders(response.RequestMessage.Headers);
100100
responseContext.Cookies = response.Headers.GetCookies();
101101
responseContext.AddHeaders(response.Headers);
102102
}

src/Sentry/Span.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Sentry.Extensibility;
2+
using Sentry.Internal;
23
using Sentry.Internal.Extensions;
34

45
namespace Sentry;
@@ -145,4 +146,9 @@ public static Span FromJson(JsonElement json)
145146
_extra = data!
146147
};
147148
}
149+
150+
internal void Redact()
151+
{
152+
Description = Description?.RedactUrl();
153+
}
148154
}

src/Sentry/Transaction.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,23 @@ public void SetMeasurement(string name, Measurement measurement) =>
298298
SpanId,
299299
IsSampled);
300300

301+
/// <summary>
302+
/// Redacts PII from the transaction
303+
/// </summary>
304+
internal void Redact()
305+
{
306+
Description = Description?.RedactUrl();
307+
foreach (var breadcrumb in Breadcrumbs)
308+
{
309+
breadcrumb.Redact();
310+
}
311+
312+
foreach (var span in Spans)
313+
{
314+
span.Redact();
315+
}
316+
}
317+
301318
/// <inheritdoc />
302319
public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
303320
{

test/Directory.Build.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<Using Include="Sentry.Testing" />
4040

4141
<Using Include="FluentAssertions" />
42+
<Using Include="FluentAssertions.Execution" />
4243
<Using Include="NSubstitute" />
4344
<Using Include="NSubstitute.Core" />
4445
<Using Include="NSubstitute.ExceptionExtensions" />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace Sentry.Tests.Internals;
2+
3+
public class PiiExtensionsTests
4+
{
5+
[Fact]
6+
public void RedactUrl_Null()
7+
{
8+
var actual = PiiExtensions.RedactUrl(null);
9+
10+
Assert.Null(actual);
11+
}
12+
13+
[Theory]
14+
[InlineData("I'm a harmless string.", "doesn't affect ordinary strings")]
15+
[InlineData("htps://user:[email protected]?q=1&s=2&token=secret#top", "doesn't affect malformed https urls")]
16+
[InlineData("htp://user:[email protected]?q=1&s=2&token=secret#top", "doesn't affect malformed http urls")]
17+
public void RedactUrl_NotNull_WithoutPii(string original, string reason)
18+
{
19+
var actual = original.RedactUrl();
20+
21+
actual.Should().Be(original, reason);
22+
}
23+
24+
[Theory]
25+
[InlineData("https://user:[email protected]?q=1&s=2&token=secret#top", "https://[Filtered]:[Filtered]@sentry.io?q=1&s=2&token=secret#top", "strips user info with user and password from https")]
26+
[InlineData("https://user:[email protected]", "https://[Filtered]:[Filtered]@sentry.io", "strips user info with user and password from https without query")]
27+
[InlineData("https://[email protected]", "https://[Filtered]@sentry.io", "strips user info with user only from https without query")]
28+
[InlineData("http://user:[email protected]?q=1&s=2&token=secret#top", "http://[Filtered]:[Filtered]@sentry.io?q=1&s=2&token=secret#top", "strips user info with user and password from http")]
29+
[InlineData("http://user:[email protected]", "http://[Filtered]:[Filtered]@sentry.io", "strips user info with user and password from http without query")]
30+
[InlineData("http://[email protected]", "http://[Filtered]@sentry.io", "strips user info with user only from http without query")]
31+
[InlineData("GET https://[email protected] for goodness", "GET https://[Filtered]@sentry.io for goodness", "strips user info from URL embedded in text")]
32+
public void RedactUrl_NotNull_WithPii(string original, string expected, string reason)
33+
{
34+
var actual = original.RedactUrl();
35+
36+
actual.Should().Be(expected, reason);
37+
}
38+
}

0 commit comments

Comments
 (0)