Handling rejections

edit

Exceptions

A request rejected when its connection is opened throws an HttpRequestException whose InnerException is a ServerSideRequestForgeryException:

ServerSideRequestForgeryException? rejection = null;
try
{
    // 127.0.0.1 is in the default UnsafeIpNetworks
    using var response = await httpClient.GetAsync(new Uri("https://127.0.0.1/"), cancellationToken);
}
catch (HttpRequestException ex) when (ex.InnerException is ServerSideRequestForgeryException ssrf)
{
    rejection = ssrf;
}

Assert.NotNull(rejection);

A request that may use HTTP/3 is rejected by ServerSideRequestForgeryClientHandler before it reaches the SocketsHttpHandler, so its ServerSideRequestForgeryException is not wrapped:

var options = new ServerSideRequestForgeryOptions();
var handler = new ServerSideRequestForgeryClientHandler(new SocketsHttpHandler { UseProxy = false }, options);
using var httpClient = new HttpClient(handler, disposeHandler: true);

using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("https://example.com/"))
{
    Version = HttpVersion.Version30,
};

// Rejected before the request reaches SocketsHttpHandler, so the exception is not wrapped
await Assert.ThrowsAsync<ServerSideRequestForgeryException>(() => httpClient.SendAsync(request));

Catch both when the URL comes from a user, and report the URL as not allowed. Other HttpRequestExceptions, such as a host that does not exist or does not answer, are ordinary network errors.

Reasons

ReasonException message
unsafe_schemeThe URI scheme '…' is not allowed.
all_resolved_addresses_unsafeNo safe IP addresses were found after validation.
mixed_addresses_disallowedThe hostname resolved to a mix of safe and unsafe IP addresses.
resolution_strategy_failureThe message of the strategy, such as "No address found for address family '…'."
selected_address_not_validatedThe resolution strategy selected an address that was not part of the validated safe set.
host_mismatchThe host resolved for the connection does not match the request URI authority.
proxy_connectionThe connection targets a proxy. …

The reason is the tag of the metric below; the HTTP/3 rejection has none.

Logging

Set Logger to log every rejection as a warning:

var options = new ServerSideRequestForgeryOptions
{
    // Every rejection is logged as a warning
    Logger = logger,
};

A rejected request to https://127.0.0.1/?token=secret logs "Rejected request to https://127.0.0.1:443. All resolved IP addresses were unsafe." The messages give the origin of the request (scheme, host and port) only: the path, the query string and credentials in the URL are never logged. The HTTP/3 rejection is not logged.

Metrics

Each rejection adds one to the meziantou.framework.http.server_side_request_forgery.rejected_requests.total counter of the Meziantou.Framework.Http.ServerSideRequestForgery meter, with a reason tag from the table above. Any metrics collector that subscribes to the meter by name receives it; this one uses a MeterListener:

var reasons = new ConcurrentQueue<string?>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, meterListener) =>
{
    if (instrument.Meter.Name is "Meziantou.Framework.Http.ServerSideRequestForgery")
    {
        meterListener.EnableMeasurementEvents(instrument);
    }
};
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
{
    foreach (var tag in tags)
    {
        if (tag.Key is "reason")
        {
            reasons.Enqueue(tag.Value as string);
        }
    }
});
listener.Start();

var options = new ServerSideRequestForgeryOptions();
using var httpClient = new HttpClient(new ServerSideRequestForgeryClientHandler(new SocketsHttpHandler { UseProxy = false }, options), disposeHandler: true);
await Assert.ThrowsAsync<HttpRequestException>(() => httpClient.GetAsync(new Uri("https://127.0.0.1/")));

Assert.Contains("all_resolved_addresses_unsafe", reasons);

A connection that fails after its address was validated is not a rejection: it is not logged or counted.