Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public static async Task LogFailedResponseAsync(HttpResponseMessage response, st
logger.LogDebug(ex, "Failed to read notification response body for diagnostic logging");
}

var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment());
var redactedUrl = LogRedaction.SanitizeWebhookUrl(webhookUrl);
var redactedBody = LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment());
redactedBody = AggressiveRedact(redactedBody);
if (string.IsNullOrEmpty(redactedBody)) redactedBody = "<redacted>";
Expand Down
68 changes: 68 additions & 0 deletions listenarr.application/Security/Redaction/LogRedaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,74 @@ public static string SanitizeUrl(string? url)
}
}

// Sanitize a notification webhook URL for logging. Keeps scheme, host and port; drops
// userinfo and the whole query string (same as SanitizeUrl); and for providers whose
// credential lives in the path rather than the query, masks the segments that carry it
// instead of dropping the whole path.
public static string SanitizeWebhookUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return "[empty-url]";

try
{
var uri = new Uri(url);
var portSuffix = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
var authority = $"{uri.Scheme}://{uri.Host}{portSuffix}";
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);

// Telegram: /bot<token>/<method> - mask the token segment, keep the method.
if (uri.Host.Equals("api.telegram.org", StringComparison.OrdinalIgnoreCase))
{
if (segments.Length > 0 && segments[0].StartsWith("bot", StringComparison.OrdinalIgnoreCase))
{
segments[0] = "bot<redacted>";
}

return $"{authority}/{string.Join('/', segments)}";
}

// Discord: /api/webhooks/<id>/<token> - keep the "webhooks" segment, mask what follows.
if (uri.Host.Equals("discord.com", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".discord.com", StringComparison.OrdinalIgnoreCase))
{
var webhooksIndex = Array.FindIndex(segments, s => s.Equals("webhooks", StringComparison.OrdinalIgnoreCase));
if (webhooksIndex >= 0)
{
for (var i = webhooksIndex + 1; i < segments.Length; i++)
{
segments[i] = "<redacted>";
}
}

return $"{authority}/{string.Join('/', segments)}";
}

// Slack: /services/<team>/<bot>/<token> - mask everything after "services".
if (uri.Host.Equals("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
{
var servicesIndex = Array.FindIndex(segments, s => s.Equals("services", StringComparison.OrdinalIgnoreCase));
if (servicesIndex >= 0)
{
for (var i = servicesIndex + 1; i < segments.Length; i++)
{
segments[i] = "<redacted>";
}
}

return $"{authority}/{string.Join('/', segments)}";
}

// Everything else (e.g. Pushover, Pushbullet, NTFY): the credential lives in the
// query string, already dropped above, so the path can be kept as-is.
return $"{authority}{uri.AbsolutePath}";
}
catch (Exception caughtEx_5) when (caughtEx_5 is not OperationCanceledException && caughtEx_5 is not OutOfMemoryException && caughtEx_5 is not StackOverflowException)
{
return "[invalid-url]";
}
}

// Sanitize user-provided text for logging (prevent log injection)
public static string SanitizeText(string? text, int maxLength = 200)
{
Expand Down
Loading