Sniffing HttpClient

Abramos el capó: el tráfico no miente

Tarde o temprano, tendrás la necesidad de depurar el tráfico HTTP de tu aplicación. Ya sea para verificar que estás enviando los datos correctos, que la respuesta es la esperada o simplemente para ver qué está pasando en el camino.

La primera opción a valorar sería observar el tráfico con logging. Quizás te interese levantar algunas cabeceras http, https://learn.microsoft.com/en-us/dotnet/core/compatibility/networking/9.0/redact-headers y si quieres algo más personalizado, usar IHttpClientLogger, aquí se explica bien https://josef.codes/customize-the-httpclient-logging-dotnet-core/. Todo esto es logging pero no es sniffing. Depende del caso, puede ser suficiente, pero si queremos ver el tráfico http en bruto, necesitaremos algo más.

Hace tiempo (mucho tiempo), usaba Fiddler pero las últimas veces que lo he probado, la experiencia no ha sido satisfactoria. “Soy yo, no eres tú Fiddler”, pero necesito conocer mundo.

Lo más natural parece buscar alternativas que estén en el mismo plano que Fiddler, así que ahí aparece Postman y Proxyman. No me podrás negar que este último tiene un nombre con gancho.

En Postman podemos arrancar un proxy para capturar el tráfico HTTP y en Proxyman… bueno, todo él es un proxy. Así que no hay que hacer nada especial, simplemente lo instalas y lo usas.

En el caso de Semantic Kernel, que es el framework que me ha empujado a ver que está sucediendo bajo el capó, muchos de los métodos de extensión de IServiceCollection aceptan un parámetro HttpClient. Eso nos deja la puerta abierta a inyectar un HttpClient personalizado. Por ejemplo, y para evitar la validación del certificado SSL (que te puede dar guerra con este tipo de herramientas de sniffing):

HttpClientHandler handler = new HttpClientHandler
{
    ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
};
HttpClient httpClient = new(handler);
// Use httpClient

Sin embargo, y después de aceptar como animal de compañía a los ficheros .http https://learn.microsoft.com/en-us/aspnet/core/test/http-files, ¿Por qué no escribir un DelegatingHandler que escriba tanto la petición como la respuesta en un fichero .http? Así, además de ver el tráfico en bruto, podremos usar las peticiones para hacer pruebas.

Para verlo, crearemos una aplicación de consola con el mínimo pero suficiente código para parecer una aplicación profesional. Tendrás que instalar los paquetes Microsoft.Extensions.DependencyInjection y Microsoft.Extensions.Http.

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddHttpClient();

using var scope = services.BuildServiceProvider().CreateScope();

var httpClientFactory = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient();

var result = await httpClient.GetStringAsync("https://jsonplaceholder.typicode.com/users");

Console.WriteLine(result);

Ahora vamos a añadir un DelegatingHandler que escriba las peticiones y respuestas en un fichero .http.

using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;

namespace ConsoleApp1;

public class HttpFileHandler(string path, IEnumerable<string> redactedHeaders) : DelegatingHandler
{
    private static readonly Lock FileLock = new();

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var content = new StringBuilder();

        content.AppendLine($"{request.Method} {request.RequestUri}");

        WriteHeaders(request.Headers, content);

        if (request.Content is not null)
        {
            WriteHeaders(request.Content.Headers, content);

            var originalStream = await request.Content.ReadAsStreamAsync(cancellationToken);

            var memoryStream = new MemoryStream();
            await originalStream.CopyToAsync(memoryStream, cancellationToken);
            memoryStream.Seek(0, SeekOrigin.Begin);

            using var requestReader = new StreamReader(memoryStream, leaveOpen: true);
            var requestContent = await requestReader.ReadToEndAsync(cancellationToken);

            if (request.Content.Headers.ContentType?.MediaType == MediaTypeNames.Application.Json)
            {
                var jsonElement = JsonSerializer.Deserialize<JsonElement>(requestContent);
                requestContent = JsonSerializer.Serialize(jsonElement, new JsonSerializerOptions
                {
                    WriteIndented = true,
                    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
                });
            }

            content.AppendLine($"{Environment.NewLine}{requestContent}");

            memoryStream.Seek(0, SeekOrigin.Begin);

            var newContent = new StreamContent(memoryStream);
            foreach (var header in request.Content.Headers)
            {
                newContent.Headers.Add(header.Key, header.Value);
            }

            request.Content = newContent;
        }

        content.AppendLine();

        var response = await base.SendAsync(request, cancellationToken);

        content.AppendLine("###");
        content.AppendLine();

        WriteHeaders(response.Headers, content, "# ");

        WriteHeaders(response.Content.Headers, content, "# ");

        var skipBodyDump =
            response.Headers.TransferEncodingChunked == true ||
            response.Content.Headers.ContentType?.MediaType == MediaTypeNames.Text.EventStream;

        if (!skipBodyDump)
        {
            // ReadAsStringAsync() buffers the content internally, so it won’t interfere with other consumers of response.Content, unlike ReadAsStreamAsync() which consumes the stream directly
            var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);

            responseContent = string.Join(Environment.NewLine,
                responseContent.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => "# " + line));

            content.AppendLine(responseContent);
            content.AppendLine();
        }
        else
        {
            content.AppendLine("# Chunked or streaming response detected. Skipping body dump.");
            content.AppendLine();
        }

        lock (FileLock)
        {
            File.AppendAllText(path,
                $"{(File.Exists(path) ? $"###{Environment.NewLine}{Environment.NewLine}{content}" : content.ToString())}");
        }

        return response;
    }

    private void WriteHeaders(HttpHeaders headers, StringBuilder content, string? prefix = null)
    {
        foreach (var header in headers)
        {
            if (redactedHeaders.Contains(header.Key, StringComparer.OrdinalIgnoreCase))
            {
                content.AppendLine($"{prefix}{header.Key}: *");
                continue;
            }

            content.AppendLine($"{prefix}{header.Key}: {string.Join(" ", header.Value)}");
        }
    }
}

Ahora, toca cambiar AddHttpClient por esto otro:

var httpFilePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "ConsoleApp1.http"));
if (File.Exists(httpFilePath))
{
    File.Delete(httpFilePath);
}

string[] redactedHeaders = [];

services.AddTransient(_ => new HttpFileHandler(httpFilePath, redactedHeaders));

services
    .AddHttpClient()
    .ConfigureHttpClientDefaults(cfg => cfg.AddHttpMessageHandler<HttpFileHandler>());

Y ya está. Ahora tendremos un fichero ConsoleApp1.http en el directorio de la aplicación con todas las peticiones y respuestas http.

Lógicamente, ten la precaución de o bien usar redactedHeaders para no volcar según que cabeceras o bien ignorar el fichero .http vía .gitignore.

Si quieres el ejemplo más o menos armado (incluyendo un método de extensión de IServiceCollection y un CurlFileHandlder adicional) lo tienes aquí https://gist.github.com/panicoenlaxbox/cee19a95afe7ca3a376697c1e07167a0.

Un saludo!


Ver también