Bifrost/tests/Bifrost.Web.Tests/RegistrationLimitTests.cs

239 lines
10 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
// The challenge service on its own: single use under contention, the bounded set, the purpose, and the difficulty it issued.
public sealed class RegistrationChallengeServiceTests
{
private sealed class MutableOptions(int difficulty) : IOptions<RegistrationOptions>
{
public RegistrationOptions Value { get; set; } = With(difficulty);
public static RegistrationOptions With(int d) => new() { Captcha = new RegistrationCaptchaOptions { Difficulty = d } };
}
private static RegistrationChallengeService NewService(IDataProtectionProvider provider, IOptions<RegistrationOptions> options, TimeProvider clock) =>
new(provider, options, clock);
[Fact]
public async Task Concurrent_consumes_of_one_challenge_yield_exactly_one_success()
{
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), new ManualClock());
for (var round = 0; round < 300; round++)
{
var challenge = service.Issue()!;
var nonce = RegistrationKit.Solve(challenge.Challenge, challenge.Difficulty);
using var start = new ManualResetEventSlim();
var tasks = Enumerable.Range(0, 16).Select(_ => Task.Factory.StartNew(
() => { start.Wait(); return service.TryConsume(challenge.Challenge, nonce); },
TaskCreationOptions.LongRunning)).ToArray();
start.Set();
var results = await Task.WhenAll(tasks);
Assert.Equal(1, results.Count(x => x));
}
}
[Fact]
public void A_full_consumed_set_refuses_to_issue_and_to_consume_until_entries_expire()
{
var clock = new ManualClock();
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), clock);
var pending = new List<(string, string)>();
for (var i = 0; i < RegistrationChallengeService.MaxConsumed + 1; i++)
{
var c = service.Issue()!;
pending.Add((c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
}
foreach (var (c, n) in pending.Take(RegistrationChallengeService.MaxConsumed))
{
Assert.True(service.TryConsume(c, n));
}
Assert.Null(service.Issue());
Assert.False(service.TryConsume(pending[^1].Item1, pending[^1].Item2));
clock.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
Assert.NotNull(service.Issue());
}
[Fact]
public void A_token_protected_for_another_purpose_is_refused()
{
var provider = new EphemeralDataProtectionProvider();
var clock = new ManualClock();
var service = NewService(provider, new MutableOptions(1), clock);
var expiry = (clock.GetUtcNow() + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var forged = provider.CreateProtector("Bifrost.Something.Else.v1").Protect($"{Guid.NewGuid():N}.{expiry}.1");
var nonce = RegistrationKit.Solve(forged, 1);
Assert.False(service.TryConsume(forged, nonce));
}
[Fact]
public void A_challenge_verifies_at_the_difficulty_it_was_issued_with()
{
var options = new MutableOptions(1);
var service = NewService(new EphemeralDataProtectionProvider(), options, new ManualClock());
var easy = service.Issue()!;
var easyNonce = RegistrationKit.Solve(easy.Challenge, 1);
// A nonce that meets 1 bit but not 12: only the embedded difficulty lets it through after the option rises.
var weak = Enumerable.Range(0, int.MaxValue).Select(i => i.ToString()).First(n =>
{
var bits = RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(easy.Challenge + ":" + n)));
return bits >= 1 && bits < 12;
});
options.Value = MutableOptions.With(12);
Assert.Equal(1, easy.Difficulty);
Assert.True(service.TryConsume(easy.Challenge, weak));
Assert.Equal(12, service.Issue()!.Difficulty);
_ = easyNonce;
}
}
public sealed class RegistrationBudgetFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Bifrost:Registration:Limits:PerAddressPermits"] = "3"
}));
builder.ConfigureServices(services => services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, PeerFilter>());
}
private sealed class PeerFilter : Microsoft.AspNetCore.Hosting.IStartupFilter
{
public Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "198.51.100.1");
return nextMiddleware();
});
next(app);
};
}
}
public sealed class RegistrationBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
private static async Task<HttpResponseMessage> ChallengeAsync(HttpClient client, string peer)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/challenge");
request.Headers.Add("X-Test-Peer", peer);
return await client.SendAsync(request);
}
[Fact]
public async Task The_request_past_the_address_budget_gets_429_and_another_address_is_unaffected()
{
using var client = factory.CreateClient();
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, "203.0.113.5");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var refused = await ChallengeAsync(client, "203.0.113.5");
using var other = await ChallengeAsync(client, "203.0.113.6");
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
Assert.True(refused.Headers.RetryAfter is not null);
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
}
[Fact]
public async Task An_IPv6_caller_owns_its_slash_64_and_a_mapped_IPv4_shares_its_IPv4_key()
{
using var client = factory.CreateClient();
string[] sameSlash64 = ["2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2:aaaa::3", "2001:db8:1:2:ffff:ffff:ffff:ffff", "2001:db8:1:2::5"];
var statuses = new List<HttpStatusCode>();
foreach (var peer in sameSlash64)
{
using var response = await ChallengeAsync(client, peer);
statuses.Add(response.StatusCode);
}
Assert.Equal(
[HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests],
statuses);
using var otherSlash64 = await ChallengeAsync(client, "2001:db8:1:3::1");
Assert.Equal(HttpStatusCode.OK, otherSlash64.StatusCode);
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, "203.0.113.50");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var mapped = await ChallengeAsync(client, "::ffff:203.0.113.50");
Assert.Equal(HttpStatusCode.TooManyRequests, mapped.StatusCode);
}
[Fact]
public async Task Challenge_and_register_spend_one_budget_and_a_refused_register_creates_nothing()
{
using var client = factory.CreateClient();
const string peer = "203.0.113.7";
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, peer);
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
{
Content = JsonContent.Create(new { identity = $"budget-{Guid.NewGuid():N}", acceptedNoticeVersion = TrainingNotice.Version })
};
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Peer", peer);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
using var scope = factory.Services.CreateScope();
Assert.DoesNotContain(scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts, x => x.NormalizedBifrostIdentity!.StartsWith("budget-"));
}
}
public sealed class RegistrationFullSetTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
{
[Fact]
public async Task A_full_consumed_set_makes_the_challenge_endpoint_answer_503()
{
var service = factory.Services.GetRequiredService<RegistrationChallengeService>();
for (var i = 0; i < RegistrationChallengeService.MaxConsumed; i++)
{
var c = service.Issue()!;
Assert.True(service.TryConsume(c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
}
using var client = factory.CreateClient();
using var response = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
using var after = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
}