Skip to content
Merged
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
107 changes: 107 additions & 0 deletions DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,113 @@ public async Task HarGenerator_WritesHarFile()
}
}

[Fact]
public async Task HarGenerator_RedactsCookiesAndSensitiveQueryParameters()
{
var plugin = new HarGeneratorPlugin(
TestDefaults.HttpClient,
NullLogger<HarGeneratorPlugin>.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<HarGeneratorPlugin>.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<HarGeneratorPlugin>.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<JsonElement> 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()
{
Expand Down
39 changes: 34 additions & 5 deletions DevProxy.Plugins/Generation/HarGeneratorPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,42 @@ 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);
Debug.Assert(log.Context is not null);

var request = log.Context.Session.Request;
var response = log.Context.Session.Response!;
var queryParams = HttpUtility.ParseQueryString(request.RequestUri?.Query ?? "");

var entry = new HarEntry
{
Expand All @@ -114,21 +143,21 @@ 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)
.SelectMany(v => v.Split(';'))
.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,
Expand All @@ -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
{
Expand Down
24 changes: 24 additions & 0 deletions DevProxy.Plugins/Models/Http.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading