diff --git a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs index 9cea37881..b120d019c 100644 --- a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs @@ -89,6 +89,113 @@ public async Task HarGenerator_WritesHarFile() } } + [Fact] + public async Task HarGenerator_RedactsCookiesAndSensitiveQueryParameters() + { + var plugin = new HarGeneratorPlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + Watch, + new TestProxyConfiguration(), + PluginConfig.Empty()); + + var entry = await GenerateSingleHarEntryAsync(plugin, CookieAndSecretsExchange()); + var request = entry.GetProperty("request"); + var response = entry.GetProperty("response"); + + Assert.All(request.GetProperty("cookies").EnumerateArray(), c => Assert.Equal("REDACTED", c.GetProperty("value").GetString())); + Assert.All(response.GetProperty("cookies").EnumerateArray(), c => Assert.Equal("REDACTED", c.GetProperty("value").GetString())); + Assert.Equal("session", response.GetProperty("cookies")[0].GetProperty("name").GetString()); + + var url = request.GetProperty("url").GetString()!; + Assert.DoesNotContain("s3cr3t", url, StringComparison.Ordinal); + Assert.DoesNotContain("abc123", url, StringComparison.Ordinal); + Assert.Contains("access_token=REDACTED", url, StringComparison.Ordinal); + Assert.Contains("sig=REDACTED", url, StringComparison.Ordinal); + Assert.Contains("page=2", url, StringComparison.Ordinal); + + var query = request.GetProperty("queryString").EnumerateArray() + .ToDictionary(q => q.GetProperty("name").GetString()!, q => q.GetProperty("value").GetString()); + Assert.Equal("REDACTED", query["access_token"]); + Assert.Equal("REDACTED", query["sig"]); + Assert.Equal("2", query["page"]); + } + + [Theory] + [InlineData("?page")] + [InlineData("??page")] + [InlineData("?access_token")] + public async Task HarGenerator_PreservesParameterLeadingQuestionMarks(string parameterName) + { + var plugin = new HarGeneratorPlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + Watch, + new TestProxyConfiguration(), + PluginConfig.Empty()); + + var log = TestExchange + .Request("GET", $"https://api.contoso.com/users?{parameterName}=2&access_token=secret") + .WithResponse(HttpStatusCode.OK) + .AsRequestLog(MessageType.InterceptedResponse); + + var entry = await GenerateSingleHarEntryAsync(plugin, log); + var request = entry.GetProperty("request"); + + Assert.Equal($"https://api.contoso.com/users?{parameterName}=2&access_token=REDACTED", + request.GetProperty("url").GetString()); + var query = request.GetProperty("queryString").EnumerateArray() + .ToDictionary(q => q.GetProperty("name").GetString()!, q => q.GetProperty("value").GetString()); + Assert.Equal("2", query[parameterName]); + Assert.Equal("REDACTED", query["access_token"]); + } + + [Fact] + public async Task HarGenerator_KeepsCookiesAndQueryParametersWhenIncludingSensitiveInformation() + { + var plugin = new HarGeneratorPlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + Watch, + new TestProxyConfiguration(), + PluginConfig.FromJson("""{ "includeSensitiveInformation": true }""")); + + var entry = await GenerateSingleHarEntryAsync(plugin, CookieAndSecretsExchange()); + var request = entry.GetProperty("request"); + var response = entry.GetProperty("response"); + + Assert.Equal("abc123", request.GetProperty("cookies")[0].GetProperty("value").GetString()); + Assert.Equal("xyz789", response.GetProperty("cookies")[0].GetProperty("value").GetString()); + Assert.Contains("access_token=s3cr3t", request.GetProperty("url").GetString(), StringComparison.Ordinal); + } + + private static RequestLog CookieAndSecretsExchange() => + TestExchange + .Request( + "GET", + "https://api.contoso.com/users?access_token=s3cr3t&page=2&sig=s3cr3t", + headers: [("Cookie", "sid=abc123; theme=dark")]) + .WithResponse( + HttpStatusCode.OK, + headers: [("Content-Type", "application/json"), ("Set-Cookie", "session=xyz789; Path=/; HttpOnly")], + body: "[]") + .AsRequestLog(MessageType.InterceptedResponse); + + private static async Task GenerateSingleHarEntryAsync(HarGeneratorPlugin plugin, RequestLog log) + { + var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(Recording([log]), CancellationToken.None)); + try + { + var har = dir.GetFiles("devproxy-*.har").Single(); + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(har.FullName)); + return doc.RootElement.GetProperty("log").GetProperty("entries").EnumerateArray().Single().Clone(); + } + finally + { + dir.Delete(recursive: true); + } + } + [Fact] public async Task HarGenerator_WritesWebSocketMessages() { diff --git a/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs b/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs index cf4728bb6..510b4c0ed 100644 --- a/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs @@ -99,6 +99,34 @@ private string GetHeaderValue(string headerName, string originalValue) return originalValue; } + private string GetCookieValue(string originalValue) => + Configuration.IncludeSensitiveInformation ? originalValue : "REDACTED"; + + private bool IsSensitiveQueryParameter(string? name) => + !Configuration.IncludeSensitiveInformation && + name is not null && + Http.SensitiveQueryParameters.Contains(name, StringComparer.OrdinalIgnoreCase); + + private string? GetUrl(Uri? uri) + { + if (uri is null || Configuration.IncludeSensitiveInformation || string.IsNullOrEmpty(uri.Query)) + { + return uri?.ToString(); + } + + // Rebuild the query string, preserving the original encoding of + // non-sensitive parameters and redacting only sensitive values. + var query = string.Join('&', uri.Query[1..].Split('&').Select(pair => + { + var parts = pair.Split('=', 2); + return parts.Length == 2 && IsSensitiveQueryParameter(HttpUtility.UrlDecode(parts[0])) + ? $"{parts[0]}=REDACTED" + : pair; + })); + + return new UriBuilder(uri) { Query = $"?{query}" }.Uri.ToString(); + } + private HarEntry CreateHarEntry(RequestLog log) { Debug.Assert(log is not null); @@ -106,6 +134,7 @@ private HarEntry CreateHarEntry(RequestLog log) var request = log.Context.Session.Request; var response = log.Context.Session.Response!; + var queryParams = HttpUtility.ParseQueryString(request.RequestUri?.Query ?? ""); var entry = new HarEntry { @@ -114,13 +143,13 @@ private HarEntry CreateHarEntry(RequestLog log) Request = new HarRequest { Method = request.Method, - Url = request.RequestUri?.ToString(), + Url = GetUrl(request.RequestUri), HttpVersion = $"HTTP/{request.HttpVersion}", Headers = [.. request.Headers.Select(h => new HarHeader { Name = h.Name, Value = GetHeaderValue(h.Name, string.Join(", ", h.Value)) })], - QueryString = [.. HttpUtility.ParseQueryString(request.RequestUri?.Query ?? "") + QueryString = [.. queryParams .AllKeys .Where(key => key is not null) - .Select(key => new HarQueryParam { Name = key, Value = HttpUtility.ParseQueryString(request.RequestUri?.Query ?? "")[key] ?? "" })], + .Select(key => new HarQueryParam { Name = key, Value = IsSensitiveQueryParameter(key) ? "REDACTED" : queryParams[key] ?? "" })], Cookies = [.. request.Headers .Where(h => string.Equals(h.Name, "Cookie", StringComparison.OrdinalIgnoreCase)) .Select(h => h.Value) @@ -128,7 +157,7 @@ private HarEntry CreateHarEntry(RequestLog log) .Select(c => { var parts = c.Split('=', 2); - return new HarCookie { Name = parts[0].Trim(), Value = parts.Length > 1 ? parts[1].Trim() : "" }; + return new HarCookie { Name = parts[0].Trim(), Value = GetCookieValue(parts.Length > 1 ? parts[1].Trim() : "") }; })], HeadersSize = request.Headers?.ToString()?.Length ?? 0, BodySize = request.HasBody ? request.Body.Length : 0, @@ -151,7 +180,7 @@ private HarEntry CreateHarEntry(RequestLog log) .Select(sc => { var parts = sc.Split(';')[0].Split('=', 2); - return new HarCookie { Name = parts[0].Trim(), Value = parts.Length > 1 ? parts[1].Trim() : "" }; + return new HarCookie { Name = parts[0].Trim(), Value = GetCookieValue(parts.Length > 1 ? parts[1].Trim() : "") }; })], Content = new HarContent { diff --git a/DevProxy.Plugins/Models/Http.cs b/DevProxy.Plugins/Models/Http.cs index a7cf0281c..89d0af224 100644 --- a/DevProxy.Plugins/Models/Http.cs +++ b/DevProxy.Plugins/Models/Http.cs @@ -240,6 +240,30 @@ internal static class Http "code" ]; + internal static readonly string[] SensitiveQueryParameters = + [ + "access_token", + "api_key", + "api-key", + "apikey", + "auth", + "client_secret", + "code", + "id_token", + "key", + "password", + "refresh_token", + "secret", + "sig", + "signature", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", + "x-goog-credential", + "x-goog-signature" + ]; + internal static readonly string[] SensitiveHeaders = [ "authorization",