Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Fixes

- Redact Authorization headers before sending events to Sentry ([#4164](https://github.com/getsentry/sentry-dotnet/pull/4164))
### Dependencies

- Bump CLI from v2.43.1 to v2.44.0 ([#4169](https://github.com/getsentry/sentry-dotnet/pull/4169))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sentry.Extensibility;
using Sentry.Internal;
using Sentry.Reflection;

namespace Sentry.AspNet.Internal;
Expand Down Expand Up @@ -54,12 +55,12 @@ public SystemWebRequestEventProcessor(IRequestPayloadExtractor payloadExtractor,

foreach (var key in context.Request.Headers.AllKeys)
{
if (!_options.SendDefaultPii
// Don't add headers which might contain PII
&& key is "Cookie" or "Authorization")
// Don't add cookies that might contain PII
if (!_options.SendDefaultPii && key is "Cookie")
{
continue;
}

@event.Request.Headers[key] = context.Request.Headers[key];
}

Expand Down
7 changes: 3 additions & 4 deletions src/Sentry.AspNetCore/ScopeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Net.Http.Headers;
using Sentry.AspNetCore.Extensions;
using Sentry.Extensibility;
using Sentry.Internal;
using Sentry.Internal.Extensions;

namespace Sentry.AspNetCore;
Expand Down Expand Up @@ -135,10 +136,8 @@ private static void SetEnv(Scope scope, HttpContext context, SentryAspNetCoreOpt
scope.Request.QueryString = context.Request.QueryString.ToString();
foreach (var requestHeader in context.Request.Headers)
{
if (!options.SendDefaultPii
// Don't add headers which might contain PII
&& (requestHeader.Key == HeaderNames.Cookie
|| requestHeader.Key == HeaderNames.Authorization))
// Don't add cookies that might contain PII
if (!options.SendDefaultPii && requestHeader.Key == HeaderNames.Cookie)
{
continue;
}
Expand Down
13 changes: 13 additions & 0 deletions src/Sentry/HttpHeadersExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Sentry.Internal;
using Sentry.Internal.Extensions;

namespace Sentry;

internal static class HttpHeadersExtensions
Expand All @@ -6,4 +9,14 @@ internal static string GetCookies(this HttpHeaders headers) =>
headers.TryGetValues("Cookie", out var values)
? string.Join("; ", values)
: string.Empty;

internal static RedactedHeaders? Redact(this Dictionary<string, string?>? headers)
{
var items = headers?.WhereNotNullValue();
if (items is null || !items.Any())
{
return null;
}
return headers!;
}
}
53 changes: 53 additions & 0 deletions src/Sentry/Internal/RedactedHeaders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Sentry.Internal.Extensions;

namespace Sentry.Internal;

internal class RedactedHeaders : IDictionary<string, string>
{
private static readonly string[] SensitiveKeys = ["Authorization", "Proxy-Authorization"];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We could be a bit more aggressive there. The Python SDK redacts a bunch of headers.

See Potential suspects.


private readonly Dictionary<string, string> _inner = new(StringComparer.OrdinalIgnoreCase);

private static string Redact(string key, string value) =>
SensitiveKeys.Contains(key, StringComparer.OrdinalIgnoreCase) ? "[Filtered]" : value;

public string this[string key]
{
get => _inner[key];
set => _inner[key] = Redact(key, value);
}

public void Add(string key, string value) => _inner.Add(key, Redact(key, value));

// Delegate rest to _inner
public bool ContainsKey(string key) => _inner.ContainsKey(key);
public bool Remove(string key) => _inner.Remove(key);
#if NET8_0_OR_GREATER
public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) => _inner.TryGetValue(key, out value);
#else
public bool TryGetValue(string key, out string value) => _inner.TryGetValue(key, out value);
#endif
public ICollection<string> Keys => _inner.Keys;
public ICollection<string> Values => _inner.Values;
public int Count => _inner.Count;
public bool IsReadOnly => false;

public void Add(KeyValuePair<string, string> item) => Add(item.Key, item.Value);
public void Clear() => _inner.Clear();
public bool Contains(KeyValuePair<string, string> item) => _inner.Contains(item);
public void CopyTo(KeyValuePair<string, string>[] array, int arrayIndex) =>
((IDictionary<string, string>)_inner).CopyTo(array, arrayIndex);
public bool Remove(KeyValuePair<string, string> item) => _inner.Remove(item.Key);
public IEnumerator<KeyValuePair<string, string>> GetEnumerator() => _inner.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _inner.GetEnumerator();

public static implicit operator RedactedHeaders(Dictionary<string, string> source)
{
var result = new RedactedHeaders();
foreach (var kvp in source)
{
result[kvp.Key] = kvp.Value; // This will sanitize
}
return result;
}
}
17 changes: 10 additions & 7 deletions src/Sentry/Protocol/Response.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public sealed class Response : ISentryJsonSerializable, ICloneable<Response>, IU
/// </summary>
public const string Type = "response";

internal Dictionary<string, string>? InternalHeaders { get; private set; }
internal RedactedHeaders? InternalHeaders { get; private set; }

/// <summary>
/// Gets or sets the HTTP response body size.
Expand All @@ -57,7 +57,7 @@ public sealed class Response : ISentryJsonSerializable, ICloneable<Response>, IU
/// <remarks>
/// If a header appears multiple times it needs to be merged according to the HTTP standard for header merging.
/// </remarks>
public IDictionary<string, string> Headers => InternalHeaders ??= new Dictionary<string, string>();
public IDictionary<string, string> Headers => InternalHeaders ??= new();

/// <summary>
/// Gets or sets the HTTP Status response code
Expand All @@ -69,10 +69,13 @@ internal void AddHeaders(IEnumerable<KeyValuePair<string, IEnumerable<string>>>
{
foreach (var header in headers)
{
Headers.Add(
header.Key,
string.Join("; ", header.Value)
);
// Always redact the Authorization header
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
Headers.Add(header.Key, PiiExtensions.RedactedText);
continue;
}
Headers.Add(header.Key, string.Join("; ", header.Value));
}
}

Expand Down Expand Up @@ -144,7 +147,7 @@ public static Response FromJson(JsonElement json)
BodySize = bodySize,
Cookies = cookies,
Data = data,
InternalHeaders = headers?.WhereNotNullValue().ToDict(),
InternalHeaders = headers.Redact(),
Copy link
Member

Choose a reason for hiding this comment

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

With the implicit operator, do we need to call the method here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We do unfortunately. InternalHeaders is nullable and that can't be done with an implicit operator.

StatusCode = statusCode
};
}
Expand Down
13 changes: 10 additions & 3 deletions src/Sentry/SentryRequest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sentry.Extensibility;
using Sentry.Internal;
using Sentry.Internal.Extensions;

namespace Sentry;
Expand Down Expand Up @@ -31,7 +32,7 @@ public sealed class SentryRequest : ISentryJsonSerializable

internal Dictionary<string, string>? InternalOther { get; private set; }

internal Dictionary<string, string>? InternalHeaders { get; private set; }
internal RedactedHeaders? InternalHeaders { get; private set; }

/// <summary>
/// Gets or sets the full request URL, if available.
Expand Down Expand Up @@ -81,7 +82,7 @@ public sealed class SentryRequest : ISentryJsonSerializable
/// If a header appears multiple times it needs to be merged according to the HTTP standard for header merging.
/// </remarks>
/// <value>The headers.</value>
public IDictionary<string, string> Headers => InternalHeaders ??= new Dictionary<string, string>();
public IDictionary<string, string> Headers => InternalHeaders ??= new();

/// <summary>
/// Gets or sets the optional environment data.
Expand All @@ -102,6 +103,12 @@ internal void AddHeaders(IEnumerable<KeyValuePair<string, IEnumerable<string>>>
{
foreach (var header in headers)
{
// Always redact the Authorization header
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
Headers.Add(header.Key, PiiExtensions.RedactedText);
continue;
}
Headers.Add(header.Key, string.Join("; ", header.Value));
}
}
Expand Down Expand Up @@ -176,7 +183,7 @@ public static SentryRequest FromJson(JsonElement json)
{
InternalEnv = env?.WhereNotNullValue().ToDict(),
InternalOther = other?.WhereNotNullValue().ToDict(),
InternalHeaders = headers?.WhereNotNullValue().ToDict(),
InternalHeaders = headers.Redact(),
Url = url,
Method = method,
Data = data,
Expand Down
118 changes: 118 additions & 0 deletions test/Sentry.Tests/Internals/RedactedHeadersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
namespace Sentry.Tests.Internals;

public class RedactedHeadersTests
{
[Fact]
public void Add_WithAuthorizationKey_ShouldStoreFilteredValue()
{
// Arrange
var headers = new RedactedHeaders();

// Act
headers.Add("Authorization", "Bearer 123");

// Assert
headers["Authorization"].Should().Be("[Filtered]");
}

[Fact]
public void IndexerSet_WithAuthorizationKey_ShouldStoreFilteredValue()
{
// Arrange
var headers = new RedactedHeaders();

// Act
headers["Authorization"] = "Bearer 456";

// Assert
headers["Authorization"].Should().Be("[Filtered]");
}

[Fact]
public void Add_WithOtherKey_ShouldStoreOriginalValue()
{
// Arrange
var headers = new RedactedHeaders();

// Act
headers.Add("User-Agent", "TestAgent");

// Assert
headers["User-Agent"].Should().Be("TestAgent");
}

[Fact]
public void IndexerGet_WithMissingKey_ShouldThrowKeyNotFoundException()
{
// Arrange
var headers = new RedactedHeaders();

// Act
var act = () => _ = headers["Missing"];

// Assert
act.Should().Throw<KeyNotFoundException>();
}

[Fact]
public void TryGetValue_WithExistingKey_ShouldReturnTrueAndValue()
{
// Arrange
var headers = new RedactedHeaders
{
["Authorization"] = "secret"
};

// Act
var success = headers.TryGetValue("Authorization", out var value);

// Assert
success.Should().BeTrue();
value.Should().Be("[Filtered]");
}

[Fact]
public void TryGetValue_WithMissingKey_ShouldReturnFalse()
{
// Arrange
var headers = new RedactedHeaders();

// Act
var success = headers.TryGetValue("Nonexistent", out var value);

// Assert
success.Should().BeFalse();
value.Should().BeNull(); // nullable context may allow this
}

[Fact]
public void ImplicitConversion_FromDictionary_ShouldRedactAuthorization()
{
// Arrange
var dict = new Dictionary<string, string>
{
{ "Authorization", "Token xyz" },
{ "Custom", "Value" }
};

// Act
RedactedHeaders headers = dict;

// Assert
headers["Authorization"].Should().Be("[Filtered]");
headers["Custom"].Should().Be("Value");
}

[Fact]
public void CaseInsensitiveKeyMatching_ShouldRedactAuthorization()
{
// Arrange
var headers = new RedactedHeaders();

// Act
headers.Add("authorization", "should be filtered");

// Assert
headers["AUTHORIZATION"].Should().Be("[Filtered]");
}
}
Loading