From 9c4ab5646a940edebfe575f8db8ec270111975b6 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 10:26:24 +0000 Subject: [PATCH 1/5] Registration: server-rendered sign-up form with script solver and time-lock fallback GET /auth/bifrost/register renders the form (notice in full, unchecked consent box); POST /auth/bifrost/register/form and the JSON endpoint share NativeRegistration. Challenge payload gains issued-at; waited path needs FallbackWaitSeconds and a stricter per-address FallbackPermits budget. Co-Authored-By: Claude Sonnet 5.5 --- .../Configuration/RegistrationOptions.cs | 12 + .../Registration/NativeRegistration.cs | 118 ++++ .../RegistrationChallengeService.cs | 93 ++- .../Features/Registration/RegistrationPage.cs | 156 +++++ .../Registration/RegistrationRateLimit.cs | 19 +- .../Shared/StartupConfigurationValidator.cs | 13 + src/Bifrost.Web/Program.cs | 158 ++++- .../Fixtures/registration-page-harness.cjs | 56 ++ .../RegistrationFormTests.cs | 589 ++++++++++++++++++ 9 files changed, 1146 insertions(+), 68 deletions(-) create mode 100644 src/Bifrost.Web/Features/Registration/NativeRegistration.cs create mode 100644 src/Bifrost.Web/Features/Registration/RegistrationPage.cs create mode 100644 tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs create mode 100644 tests/Bifrost.Web.Tests/RegistrationFormTests.cs diff --git a/src/Bifrost.Web/Configuration/RegistrationOptions.cs b/src/Bifrost.Web/Configuration/RegistrationOptions.cs index 24ed26e..f1b7c5b 100644 --- a/src/Bifrost.Web/Configuration/RegistrationOptions.cs +++ b/src/Bifrost.Web/Configuration/RegistrationOptions.cs @@ -9,6 +9,10 @@ public sealed class RegistrationOptions public const int MaxDifficulty = 30; + public const int MinFallbackWaitSeconds = 5; + + public const int MaxFallbackWaitSeconds = 300; + public RegistrationCaptchaOptions Captcha { get; init; } = new(); public RegistrationLimitsOptions Limits { get; init; } = new(); @@ -22,10 +26,18 @@ public sealed class RegistrationLimitsOptions public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1); public int MaxTrackedKeys { get; init; } = 10_000; + + /// Time-lock submissions (no script, no proof of work) per address per ; far tighter than because they cost the client nothing. + public int FallbackPermits { get; init; } = 3; + + public TimeSpan FallbackWindow { get; init; } = TimeSpan.FromHours(1); } public sealed class RegistrationCaptchaOptions { /// Leading zero bits the SHA-256 of challenge:nonce must have. 18 is about 262k hashes, a second or two in a browser. public int Difficulty { get; init; } = 18; + + /// Seconds after the challenge was issued before a waited (no proof of work) submission is accepted. + public int FallbackWaitSeconds { get; init; } = 30; } diff --git a/src/Bifrost.Web/Features/Registration/NativeRegistration.cs b/src/Bifrost.Web/Features/Registration/NativeRegistration.cs new file mode 100644 index 0000000..d7c57db --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/NativeRegistration.cs @@ -0,0 +1,118 @@ +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Bifrost.Web.Features.Registration; + +/// What a client sent to register. claims the time-lock path instead of a proof of work. +public sealed record RegistrationAttempt( + string? Identity, + string? DisplayName, + string? Challenge, + string? Nonce, + bool Waited, + int? AcceptedNoticeVersion); + +/// Which input a refusal is about, so the form can mark it invalid. +public enum RegistrationField { None, Identity, Consent } + +/// +/// Either a signed-in , or a to return as is (the rate limits), or a rejection +/// with a status and message. is set when the time-lock was not over; the challenge is then still unspent. +/// +public sealed record RegistrationOutcome( + UserAccount? Account, + IResult? Refusal, + int Status, + string? Message, + RegistrationField Field, + TimeSpan? WaitRemaining) +{ + public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, TimeSpan? wait = null) => + new(null, null, status, message, field, wait); +} + +/// +/// The one registration gate, run by the JSON endpoint and the form alike. Order: address budget, consent, one proof only, +/// the challenge (proof of work, or the time-lock plus its tighter budget), then the account and receipt in one save, +/// then the cookie. A refusal at any step creates nothing. +/// +public sealed class NativeRegistration( + BifrostIdentityService identities, + RegistrationChallengeService challenges, + RegistrationRateLimit rateLimit) +{ + public async Task RunAsync(HttpContext http, RegistrationAttempt attempt, CancellationToken cancellationToken) + { + if (rateLimit.Refuse(http) is { } refused) + { + return new(null, refused, 0, null, RegistrationField.None, null); + } + + // Consent first, then the proof; a refusal here creates nothing. + if (attempt.AcceptedNoticeVersion != TrainingNotice.Version) + { + return RegistrationOutcome.Reject( + StatusCodes.Status400BadRequest, + $"Registration requires accepting the training notice, current version {TrainingNotice.Version}.", + RegistrationField.Consent); + } + + var hasNonce = !string.IsNullOrEmpty(attempt.Nonce); + if (hasNonce && attempt.Waited) + { + return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Send a nonce or waited, not both."); + } + + var failed = RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Registration challenge failed."); + if (hasNonce) + { + if (!challenges.TryConsume(attempt.Challenge, attempt.Nonce)) + { + return failed; + } + } + else if (attempt.Waited) + { + if (challenges.WaitRemaining(attempt.Challenge) is not { } remaining) + { + return failed; + } + + if (remaining > TimeSpan.Zero) + { + var seconds = (int)Math.Ceiling(remaining.TotalSeconds); + return RegistrationOutcome.Reject( + StatusCodes.Status400BadRequest, + $"Too early: {seconds} more second{(seconds == 1 ? "" : "s")} to wait after the page opened. Nothing was spent; submit again then.", + wait: remaining); + } + + if (rateLimit.RefuseFallback(http) is { } fallbackRefused) + { + return new(null, fallbackRefused, 0, null, RegistrationField.None, null); + } + + if (!challenges.TryConsumeWaited(attempt.Challenge)) + { + return failed; + } + } + else + { + return failed; + } + + try + { + var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, TrainingNotice.Version, cancellationToken); + await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, identities.BuildPrincipal(account)); + return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, null); + } + catch (BifrostIdentityException error) + { + return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity); + } + } +} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs index c44c3fb..feb3cb9 100644 --- a/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs +++ b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs @@ -50,58 +50,87 @@ public sealed class RegistrationChallengeService( var expiresAt = now + Lifetime; var difficulty = Difficulty; var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); - var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}"); + var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}.{now.ToUnixTimeMilliseconds()}"); return new RegistrationChallenge(challenge, difficulty, expiresAt); } + /// Proof-of-work path: the nonce must meet the difficulty the challenge was issued with. Spends the challenge. public bool TryConsume(string? challenge, string? nonce) { - if (string.IsNullOrEmpty(challenge) || !IsNonce(nonce)) - { - return false; - } - - string id; - int difficulty; - DateTimeOffset expiresAt; - try - { - var payload = protector.Unprotect(challenge).Split('.'); - if (payload.Length != 3) - { - return false; - } - - id = payload[0]; - difficulty = int.Parse(payload[2], System.Globalization.CultureInfo.InvariantCulture); - expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], System.Globalization.CultureInfo.InvariantCulture)); - } - catch (Exception) - { - return false; - } - - var now = clock.GetUtcNow(); - if (now >= expiresAt) + if (!IsNonce(nonce) || Open(challenge) is not { } open) { return false; } var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce)); - if (LeadingZeroBits(hash) < difficulty) + return LeadingZeroBits(hash) >= open.Difficulty && Spend(open); + } + + /// + /// Time-lock path, step one: how long until a waited submission of this challenge is accepted. Null when the + /// token is not a live challenge of ours; zero when the wait is over. Spends nothing. + /// + public TimeSpan? WaitRemaining(string? challenge) + { + if (Open(challenge) is not { } open) { - return false; + return null; } + var remaining = open.IssuedAt + TimeSpan.FromSeconds(options.Value.Captcha.FallbackWaitSeconds) - clock.GetUtcNow(); + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + /// Time-lock path, step two: spends the challenge if its wait is over. No proof of work is checked. + public bool TryConsumeWaited(string? challenge) => + WaitRemaining(challenge) == TimeSpan.Zero && Open(challenge) is { } open && Spend(open); + + private sealed record OpenChallenge(string Id, int Difficulty, DateTimeOffset ExpiresAt, DateTimeOffset IssuedAt); + + /// The payload of a challenge that we protected and that has not expired; null otherwise. + private OpenChallenge? Open(string? challenge) + { + if (string.IsNullOrEmpty(challenge)) + { + return null; + } + + OpenChallenge open; + try + { + var payload = protector.Unprotect(challenge).Split('.'); + if (payload.Length != 4) + { + return null; + } + + var culture = System.Globalization.CultureInfo.InvariantCulture; + open = new OpenChallenge( + payload[0], + int.Parse(payload[2], culture), + DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], culture)), + DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[3], culture))); + } + catch (Exception) + { + return null; + } + + return clock.GetUtcNow() >= open.ExpiresAt ? null : open; + } + + private bool Spend(OpenChallenge open) + { + var now = clock.GetUtcNow(); lock (gate) { Purge(now); - if (consumed.Count >= MaxConsumed || !consumed.Add(id)) + if (consumed.Count >= MaxConsumed || !consumed.Add(open.Id)) { return false; } - expiries.Enqueue(id, expiresAt); + expiries.Enqueue(open.Id, open.ExpiresAt); return true; } } diff --git a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs new file mode 100644 index 0000000..6167ca1 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Text; + +namespace Bifrost.Web.Features.Registration; + +/// What the sign-up page shows. Everything user-supplied is HTML-encoded on the way out. +public sealed record RegistrationPageModel( + string ReturnPath, + string Challenge, + int Difficulty, + int WaitSeconds, + string Identity = "", + string DisplayName = "", + string? Error = null, + RegistrationField ErrorField = RegistrationField.None); + +/// +/// The server-rendered sign-up form. It works with no script (time-lock path); the inline script, when it can, replaces the +/// wait by solving the proof of work. No external asset, no cookie, no third party. +/// +public static class RegistrationPage +{ + // Hash rule is RegistrationChallengeService's: SHA-256 of challenge + ":" + nonce, leading zero bits >= difficulty. + // Hashes run in batches that yield to the UI between them. If the browser has no crypto.subtle, nothing here runs + // and the form submits on the time-lock path. + public const string Script = """ + (function () { + var form = document.getElementById("register"); + var status = document.getElementById("status"); + if (!form || !status || !window.crypto || !window.crypto.subtle || !window.TextEncoder) { return; } + var BATCH = 256; + var busy = false; + var solved = false; + + function zeroBits(bytes) { + var bits = 0; + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) { bits += 8; continue; } + return bits + Math.clz32(bytes[i]) - 24; + } + return bits; + } + + async function solve(challenge, difficulty) { + var encoder = new TextEncoder(); + for (var start = 0; ; start += BATCH) { + var tries = []; + for (var n = start; n < start + BATCH; n++) { + tries.push(crypto.subtle.digest("SHA-256", encoder.encode(challenge + ":" + n))); + } + var hashes = await Promise.all(tries); + for (var i = 0; i < hashes.length; i++) { + if (zeroBits(new Uint8Array(hashes[i])) >= difficulty) { return String(start + i); } + } + await new Promise(function (resolve) { setTimeout(resolve, 0); }); + } + } + + form.addEventListener("submit", function (event) { + if (solved) { return; } + event.preventDefault(); + if (busy) { return; } + busy = true; + status.textContent = "Checking, a few seconds"; + solve(form.elements.challenge.value, parseInt(form.getAttribute("data-difficulty"), 10)).then(function (nonce) { + form.elements.nonce.value = nonce; + form.elements.waited.disabled = true; + solved = true; + status.textContent = "Creating your account"; + form.submit(); + }, function () { + solved = true; + busy = false; + status.textContent = "The quick check did not run in this browser. Press Create account again after the wait described below."; + }); + }); + })(); + """; + + public static string Render(RegistrationPageModel model) + { + static string E(string? text) => WebUtility.HtmlEncode(text ?? ""); + var notice = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); + var summary = new StringBuilder(); + if (model.Error is not null) + { + summary.Append($""""""); + } + + string Invalid(RegistrationField field) => model.Error is not null && model.ErrorField == field ? " aria-invalid=\"true\" aria-describedby=\"errors\"" : ""; + var wait = model.WaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return $$""" + + + + + + Create an account - Bifrost + + + +
+ Public registration +

Create a Bifrost account

+

A new account can chat. It has no vote and opens no member or governance surface.

+ {{summary}} +
+
+ Your account + + + + +
+
+ Training notice, version {{notice}} +

{{E(TrainingNotice.Text)}}

+ + +
+ + + + +

+ +
+

Without a script, this page cannot run its quick check. Then wait {{wait}} seconds after the page opened and press Create account; pressed sooner, the page comes back saying how many seconds remain, and nothing is lost. Without the quick check only a few registrations per hour are accepted from one address.

+

After registering, Bifrost returns you to {{E(model.ReturnPath)}}.

+
+ + + + """; + } +} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs b/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs index 6dc57d6..d95e7f3 100644 --- a/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs +++ b/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs @@ -17,18 +17,31 @@ public sealed class RegistrationRateLimit(IOptions options, options.Value.Limits.MaxTrackedKeys, clock); + // Time-lock submissions cost the client no work, so they get their own, much tighter budget per address. + private readonly FixedWindowBudget fallbacks = new( + options.Value.Limits.FallbackPermits, + options.Value.Limits.FallbackWindow, + options.Value.Limits.MaxTrackedKeys, + clock); + /// Null when the request may proceed; otherwise the refusal to return. - public IResult? Refuse(HttpContext http) + public IResult? Refuse(HttpContext http) => Refuse(http, addresses, "Too many registration requests from this address."); + + /// The stricter budget for a submission that passed the time-lock instead of the proof of work. + public IResult? RefuseFallback(HttpContext http) => + Refuse(http, fallbacks, "Too many registrations without the quick check from this address; try again later or let the page run its script."); + + private IResult? Refuse(HttpContext http, FixedWindowBudget budget, string message) { try { - if (addresses.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial) + if (budget.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial) { return null; } http.Response.Headers.RetryAfter = RetryAfter(denial.RetryAfter); - return Results.Text("Too many registration requests from this address.", statusCode: StatusCodes.Status429TooManyRequests); + return Results.Text(message, statusCode: StatusCodes.Status429TooManyRequests); } catch (Exception exception) when (exception is not OperationCanceledException) { diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 6689ce1..3181450 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Registration; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Shared; @@ -55,12 +56,24 @@ public sealed class StartupConfigurationValidator( failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}."); } + var wait = registrationOptions.Value.Captcha.FallbackWaitSeconds; + if (wait < RegistrationOptions.MinFallbackWaitSeconds || wait > RegistrationOptions.MaxFallbackWaitSeconds || + wait >= RegistrationChallengeService.Lifetime.TotalSeconds) + { + failures.Add($"Bifrost:Registration:Captcha:FallbackWaitSeconds must be between {RegistrationOptions.MinFallbackWaitSeconds} and {RegistrationOptions.MaxFallbackWaitSeconds} and shorter than the challenge lifetime ({RegistrationChallengeService.Lifetime.TotalSeconds:0} seconds)."); + } + var limits = registrationOptions.Value.Limits; if (limits.PerAddressPermits < 1 || limits.Window <= TimeSpan.Zero || limits.MaxTrackedKeys < 1) { failures.Add("Bifrost:Registration:Limits:PerAddressPermits and MaxTrackedKeys must be at least 1 and Window must be positive."); } + if (limits.FallbackPermits < 1 || limits.FallbackWindow <= TimeSpan.Zero) + { + failures.Add("Bifrost:Registration:Limits:FallbackPermits must be at least 1 and FallbackWindow must be positive."); + } + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) { Refuse(failures); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 7fc1854..17dcd56 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -135,6 +135,7 @@ builder.Services.AddSingleton(); // TimeProvider and option builder.Services.AddOptions().BindConfiguration(RegistrationOptions.SectionName); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -389,54 +390,114 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions +{ + var outcome = await registration.RunAsync( + httpContext, + new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion), + cancellationToken); + if (outcome.Refusal is not null) + { + return outcome.Refusal; + } + + if (outcome.Account is not { } userAccount) + { + return Results.Text(outcome.Message!, statusCode: outcome.Status); + } + + var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl; + return JsonResult( + new NativeBifrostRegistrationResponse( + userAccount.Id, + userAccount.BifrostIdentity, + userAccount.DisplayName, + userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(), + returnUrl), + appJsonSerializerOptions); +}).AllowAnonymous(); + +// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge. +app.MapGet("/auth/bifrost/register", ( + HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit, - CancellationToken cancellationToken) => + IOptions registrationOptions) => { if (rateLimit.Refuse(httpContext) is { } refused) { return refused; } - // Consent first, then the proof of work; a refusal here creates nothing. - if (request.AcceptedNoticeVersion != TrainingNotice.Version) + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); + return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK); +}).AllowAnonymous(); + +// Antiforgery is not used: the form carries nothing of a session (an anonymous visitor has none) and the single-use +// signed challenge in it is the only credential, so there is no session to ride. +app.MapPost("/auth/bifrost/register/form", async ( + HttpContext httpContext, + NativeRegistration registration, + RegistrationChallengeService challenges, + IOptions registrationOptions, + CancellationToken cancellationToken) => +{ + if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) { - return Results.Text( - $"Registration requires accepting the training notice, current version {TrainingNotice.Version}.", - statusCode: StatusCodes.Status400BadRequest); - } - - if (!challenges.TryConsume(request.Challenge, request.Nonce)) - { - return Results.Text("Registration challenge failed.", statusCode: StatusCodes.Status400BadRequest); + return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType); } + IFormCollection form; try { - var userAccount = await bifrostIdentityService.RegisterNativeAsync( - request.Identity, - request.DisplayName, - TrainingNotice.Version, - cancellationToken); - await httpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - bifrostIdentityService.BuildPrincipal(userAccount)); - - var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl; - return JsonResult( - new NativeBifrostRegistrationResponse( - userAccount.Id, - userAccount.BifrostIdentity, - userAccount.DisplayName, - userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(), - returnUrl), - appJsonSerializerOptions); + form = await httpContext.Request.ReadFormAsync(cancellationToken); } - catch (BifrostIdentityException error) + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest); } + + string Field(string name) => form[name].FirstOrDefault() ?? ""; + var returnPath = LocalReturnPath.Parse(Field("returnUrl")); + // The checkbox is the consent act: without it the posted notice version is not taken as accepted. + int? accepted = Field("consent") == "yes" && int.TryParse(Field("acceptedNoticeVersion"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var version) + ? version + : null; + var identity = Field("identity"); + var displayName = Field("displayName"); + var challenge = Field("challenge"); + + var outcome = await registration.RunAsync( + httpContext, + new RegistrationAttempt(identity, displayName, challenge, Field("nonce"), Field("waited") == "true", accepted), + cancellationToken); + if (outcome.Refusal is not null) + { + return outcome.Refusal; + } + + if (outcome.Account is not null) + { + // A Location header takes ASCII only; anything else is not a path worth keeping. + var target = returnPath.Value.All(c => c > ' ' && c < '\u007f') ? returnPath.Value : LocalReturnPath.Default.Value; + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers.Location = target; + return Results.StatusCode(StatusCodes.Status303SeeOther); + } + + // Only a time-lock refusal leaves the challenge unspent and worth keeping: its clock must not restart. Any other + // failure gets a fresh challenge, and the consent box comes back unchecked. + var options = registrationOptions.Value; + return RegistrationFormResult( + httpContext, + challenges, + options, + new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field), + outcome.WaitRemaining is not null ? challenge : null, + outcome.Status); + + static string Truncate(string text) => text.Length <= 256 ? text : text[..256]; }).AllowAnonymous(); app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) => @@ -814,6 +875,36 @@ static async Task ReadFormOrNullAsync(HttpRequest request, Can } } +// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full). +static IResult RegistrationFormResult( + HttpContext httpContext, + RegistrationChallengeService challenges, + RegistrationOptions options, + RegistrationPageModel model, + string? keepChallenge, + int statusCode) +{ + var token = keepChallenge; + var difficulty = options.Captcha.Difficulty; + if (token is null) + { + var issued = challenges.Issue(); + if (issued is null) + { + return Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable); + } + + token = issued.Challenge; + difficulty = issued.Difficulty; + } + + httpContext.Response.Headers.CacheControl = "no-store"; + return Results.Content( + RegistrationPage.Render(model with { Challenge = token, Difficulty = difficulty }), + "text/html; charset=utf-8", + statusCode: statusCode); +} + static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) { httpContext.Response.ContentType = "text/html; charset=utf-8"; @@ -870,7 +961,8 @@ public sealed record NativeBifrostRegistrationRequest( string? ReturnUrl, string? Challenge, string? Nonce, - int? AcceptedNoticeVersion); + int? AcceptedNoticeVersion, + bool? Waited = null); public sealed record NativeBifrostRegistrationResponse( Guid UserAccountId, diff --git a/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs new file mode 100644 index 0000000..facc26a --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs @@ -0,0 +1,56 @@ +// Runs the sign-up page's real inline script against a simulated browser and prints what it did as JSON. +// Usage: node registration-page-harness.cjs +const fs = require("fs"); +const vm = require("vm"); +const { webcrypto } = require("crypto"); + +const html = fs.readFileSync(process.argv[2], "utf8"); +const scripts = [...html.matchAll(/\">"; + + using var response = await FormKit.PostAsync(client, FormKit.Fields(html, hostile, consent: false)); + var again = await response.Content.ReadAsStringAsync(); + + Assert.DoesNotContain(" diff --git a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs b/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs similarity index 79% rename from src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs rename to src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs index ffd1b47..398b3fd 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs +++ b/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs @@ -1,14 +1,14 @@ using Bifrost.Web.Configuration; using Microsoft.AspNetCore.Http; -namespace Bifrost.Web.Features.ConceptVotes; +namespace Bifrost.Web.Features.Shared; -// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous -// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead +// The one place that decides whether a state-changing request to an anonymous form (the vote page, the sign-up form) came from +// the site itself. These flows carry no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead // (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser // sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header // are all refused. Header values are compared, never echoed. -public static class CastRequestGuard +public static class RequestOriginGuard { public static bool Allows(HttpRequest request, BifrostHostOptions host) { diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index dd87f6d..41ed934 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -29,7 +29,7 @@ public sealed class VoteModel( // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own - // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. + // origin (RequestOriginGuard); no antiforgery cookie or token exists on this flow. public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) { var method = context.HttpContext.Request.Method; @@ -50,7 +50,7 @@ public sealed class VoteModel( return; } - if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) + if (HttpMethods.IsPost(method) && !RequestOriginGuard.Allows(context.HttpContext.Request, host.Value)) { context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); return; diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 17dcd56..ac7c8dd 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -391,8 +391,14 @@ app.MapPost("/auth/bifrost/register", async ( NativeBifrostRegistrationRequest request, HttpContext httpContext, NativeRegistration registration, + RegistrationRateLimit rateLimit, CancellationToken cancellationToken) => { + if (rateLimit.Refuse(httpContext) is { } refused) + { + return refused; + } + var outcome = await registration.RunAsync( httpContext, new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion), @@ -434,25 +440,55 @@ app.MapGet("/auth/bifrost/register", ( return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK); }).AllowAnonymous(); -// Antiforgery is not used: the form carries nothing of a session (an anonymous visitor has none) and the single-use -// signed challenge in it is the only credential, so there is no session to ride. +// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead +// (RequestOriginGuard, the same Fetch Metadata check as the vote page). Without it a foreign page could post here with +// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent. +// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie. app.MapPost("/auth/bifrost/register/form", async ( HttpContext httpContext, NativeRegistration registration, RegistrationChallengeService challenges, + RegistrationRateLimit rateLimit, + IOptions hostOptions, IOptions registrationOptions, CancellationToken cancellationToken) => { + if (!RequestOriginGuard.Allows(httpContext.Request, hostOptions.Value)) + { + return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden); + } + + if (rateLimit.Refuse(httpContext) is { } refused) + { + return refused; + } + if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) { return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType); } + // The form is a handful of short fields: refuse a big body before reading any of it. + const int MaxFormBytes = 16 * 1024; + if (httpContext.Request.ContentLength > MaxFormBytes) + { + return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge); + } + + if (httpContext.Features.Get() is { IsReadOnly: false } limit) + { + limit.MaxRequestBodySize = MaxFormBytes; + } + IFormCollection form; try { form = await httpContext.Request.ReadFormAsync(cancellationToken); } + catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge) + { + return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge); + } catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) { return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest); @@ -486,15 +522,15 @@ app.MapPost("/auth/bifrost/register/form", async ( return Results.StatusCode(StatusCodes.Status303SeeOther); } - // Only a time-lock refusal leaves the challenge unspent and worth keeping: its clock must not restart. Any other - // failure gets a fresh challenge, and the consent box comes back unchecked. + // A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its + // challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked. var options = registrationOptions.Value; return RegistrationFormResult( httpContext, challenges, options, new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field), - outcome.WaitRemaining is not null ? challenge : null, + outcome.KeepChallenge ? challenge : null, outcome.Status); static string Truncate(string text) => text.Length <= 256 ? text : text[..256]; diff --git a/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs index facc26a..7122a76 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs +++ b/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs @@ -13,7 +13,7 @@ const script = scripts[0][1]; const challenge = html.match(/ (sawSubmit = resolve)); const form = { elements: { challenge: { value: challenge }, nonce: { value: "" }, waited: { disabled: false } }, - getAttribute: (n) => (n === "data-difficulty" ? difficulty : null), + getAttribute: (n) => (n === "data-difficulty" ? difficulty : n === "data-wait" ? "30" : null), addEventListener: (type, fn) => { if (type === "submit") handler = fn; }, @@ -41,6 +41,13 @@ async function run(name, { withCrypto, submits }) { if (!handler) return { name, listening: false, statuses, submitted, nonce: "", waitedDisabled: false, prevented: [], challenge, difficulty }; const prevented = []; for (let i = 0; i < submits; i++) handler({ preventDefault: () => prevented.push(true) }); + if (skipAfter) { + // The skip button pressed while the solve is in flight: the script must let this submit through and drop the solve. + const skipPrevented = []; + handler({ preventDefault: () => skipPrevented.push(true), submitter: { id: "skip" } }); + await new Promise((r) => setTimeout(r, 200)); + return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented: skipPrevented, challenge, difficulty }; + } await done; await new Promise((r) => setTimeout(r, 20)); return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented, challenge, difficulty }; @@ -50,6 +57,7 @@ async function run(name, { withCrypto, submits }) { const out = [ await run("solves", { withCrypto: true, submits: 1 }), await run("double-press", { withCrypto: true, submits: 2 }), + await run("skip-during-solve", { withCrypto: true, submits: 1, skipAfter: true }), await run("no-subtle", { withCrypto: false, submits: 0 }), ]; console.log(JSON.stringify(out)); diff --git a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs index ecde22f..b271caa 100644 --- a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs @@ -10,6 +10,7 @@ using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -62,8 +63,24 @@ internal static class FormKit return await response.Content.ReadAsStringAsync(); } - public static Task PostAsync(HttpClient client, Dictionary fields) => - client.PostAsync("/auth/bifrost/register/form", new FormUrlEncodedContent(fields)); + public const string SiteOrigin = "https://localhost"; + + // A browser on the site's own page sends its Origin; the guard refuses a post without one. + public static Task PostAsync(HttpClient client, Dictionary fields, string? origin = SiteOrigin, string? peer = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = new FormUrlEncodedContent(fields) }; + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + + if (peer is not null) + { + request.Headers.Add("X-Test-Peer", peer); + } + + return client.SendAsync(request); + } public static Dictionary Fields(string html, string identity, bool consent = true, string? nonce = null, bool waited = false, string? returnUrl = null) { @@ -149,13 +166,16 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Equal(TrainingNotice.Version.ToString(), FormKit.Hidden(html, "acceptedNoticeVersion")); Assert.False(string.IsNullOrWhiteSpace(FormKit.Hidden(html, "challenge"))); Assert.Matches("wait \\d+ seconds", html); + Assert.Matches("", html); + Assert.DoesNotContain("a few seconds", html); + Assert.DoesNotContain("nothing is lost", html); + Assert.Contains("tick the box again", html); Assert.DoesNotContain("]*aria-invalid=\"true\" aria-describedby=\"errors\"", again); + Assert.Contains("", again); Assert.DoesNotMatch("]*\\bchecked\\b", again); Assert.Contains($"value=\"{identity}\"", again); Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge")); @@ -290,6 +311,8 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Contains("already registered", again); Assert.Matches("id=\"identity\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again); Assert.Contains($"value=\"{identity}\"", again); + Assert.Contains("", again); + Assert.Equal(FormKit.Hidden(second, "challenge"), FormKit.Hidden(again, "challenge")); } [Fact] @@ -419,11 +442,133 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl public async Task A_form_must_be_urlencoded() { using var client = Client(); - using var response = await client.PostAsync("/auth/bifrost/register/form", JsonContent.Create(new { identity = "x" })); + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = JsonContent.Create(new { identity = "x" }) }; + request.Headers.Add("Origin", FormKit.SiteOrigin); + using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); } + public static TheoryData CrossSite => new() + { + { "https://evil.example", null }, + { "https://evil.example", "same-origin" }, + { "null", null }, + { "https://localhost.evil.example", null }, + { null, "cross-site" }, + { null, "same-site" }, + { null, "none" }, + { null, null } + }; + + [Theory] + [MemberData(nameof(CrossSite))] + public async Task A_cross_site_or_unattributed_post_is_refused_before_anything_is_read_or_spent(string? origin, string? fetchSite) + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var identity = NewIdentity(); + var before = Counts(); + factory.Time.Advance(TimeSpan.FromSeconds(31)); + var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") + { + Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, waited: true)) + }; + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + + if (fetchSite is not null) + { + request.Headers.Add("Sec-Fetch-Site", fetchSite); + } + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + Assert.Equal(before, Counts()); + // The challenge was not spent by the refusal. + using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true)); + Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode); + } + + [Fact] + public async Task A_same_origin_post_without_an_Origin_header_is_accepted_on_Sec_Fetch_Site() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") + { + Content = new FormUrlEncodedContent(FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html))) + }; + request.Headers.Add("Sec-Fetch-Site", "same-origin"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + } + + [Fact] + public async Task A_form_body_over_the_cap_is_refused_with_413_and_creates_nothing() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var fields = FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html)); + fields["padding"] = new string('x', 17 * 1024); + var before = Counts(); + + using var response = await FormKit.PostAsync(client, fields); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + Assert.Equal(before, Counts()); + } + + [Theory] + [InlineData("support", "reserved")] + [InlineData("a!", "3-80 characters")] + public async Task A_bad_identity_is_refused_without_spending_the_challenge(string identity, string fragment) + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var nonce = FormKit.Solved(html); + + using var bad = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce)); + var again = await bad.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + Assert.Contains(fragment, again); + Assert.Equal(FormKit.Hidden(html, "challenge"), FormKit.Hidden(again, "challenge")); + + var good = NewIdentity(); + using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, good, nonce: nonce)); + Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode); + Assert.Equal((true, true), Created(good)); + } + + [Theory] + [InlineData(3)] + [InlineData(5)] + public async Task A_challenge_payload_of_another_shape_is_refused(int parts) + { + using var client = Client(); + var protector = factory.Services.GetRequiredService() + .CreateProtector(RegistrationChallengeService.Purpose); + var now = factory.Time.GetUtcNow(); + var expiry = (now + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds(); + var issued = (now - TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds(); + var fields = new[] { Guid.NewGuid().ToString("N"), expiry.ToString(), "1", issued.ToString(), "extra" }; + var challenge = protector.Protect(string.Join('.', fields.Take(parts))); + var identity = NewIdentity(); + + using var pow = await RegistrationKit.PostAsync(client, new { identity, challenge, nonce = RegistrationKit.Solve(challenge, 1), acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true); + using var waited = await RegistrationKit.PostAsync(client, new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true); + + Assert.Equal(HttpStatusCode.BadRequest, pow.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, waited.StatusCode); + Assert.False(Exists(identity)); + } + [Fact] public async Task The_page_solver_script_finds_a_nonce_the_server_accepts_under_node() { @@ -458,7 +603,11 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl var solved = runs["solves"]; Assert.Equal(1, solved.GetProperty("submitted").GetInt32()); Assert.True(solved.GetProperty("waitedDisabled").GetBoolean()); - Assert.Equal("Checking, a few seconds", solved.GetProperty("statuses")[0].GetString()); + var first = solved.GetProperty("statuses")[0].GetString()!; + Assert.StartsWith("Checking.", first); + Assert.Contains("Skip the automatic check", first); + Assert.Contains("slow device", first); + Assert.DoesNotContain("a few seconds", first); var nonce = solved.GetProperty("nonce").GetString()!; var challenge = FormKit.Hidden(html, "challenge"); Assert.True(RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData( @@ -470,6 +619,12 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); Assert.Equal((true, true), Created(identity)); + var skip = runs["skip-during-solve"]; + Assert.Equal(0, skip.GetProperty("submitted").GetInt32()); + Assert.Equal("", skip.GetProperty("nonce").GetString()); + Assert.False(skip.GetProperty("waitedDisabled").GetBoolean()); + Assert.Equal(0, skip.GetProperty("prevented").GetArrayLength()); + Assert.Equal(1, runs["double-press"].GetProperty("submitted").GetInt32()); Assert.False(runs["no-subtle"].GetProperty("listening").GetBoolean()); } @@ -587,3 +742,69 @@ public sealed class RegistrationFallbackOptionsTests Assert.Contains("FallbackPermits", Assert.Throws(() => Validate(options)).Message); } } + +public sealed class RegistrationValidationBudgetFactory : RegistrationFormFactory +{ + protected override string FallbackPermits => "2"; +} + +public sealed class RegistrationValidationBudgetTests(RegistrationValidationBudgetFactory factory) : IClassFixture +{ + [Fact] + public async Task Identity_refusals_spend_neither_a_fallback_permit_nor_the_challenge() + { + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var first = await FormKit.PageAsync(client); + var second = await FormKit.PageAsync(client); + var third = await FormKit.PageAsync(client); + factory.Time.Advance(TimeSpan.FromSeconds(31)); + var taken = $"vb-{Guid.NewGuid():N}"; + + using (var one = await FormKit.PostAsync(client, FormKit.Fields(first, taken, waited: true))) + { + Assert.Equal(HttpStatusCode.SeeOther, one.StatusCode); + } + + foreach (var bad in new[] { taken, "support", "a!" }) + { + using var refused = await FormKit.PostAsync(client, FormKit.Fields(second, bad, waited: true)); + Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); + } + + using (var fresh = await FormKit.PostAsync(client, FormKit.Fields(second, $"vb-{Guid.NewGuid():N}", waited: true))) + { + Assert.Equal(HttpStatusCode.SeeOther, fresh.StatusCode); + } + + // Two permits, two registrations: the three refusals above cost nothing. + using var over = await FormKit.PostAsync(client, FormKit.Fields(third, $"vb-{Guid.NewGuid():N}", waited: true)); + Assert.Equal(HttpStatusCode.TooManyRequests, over.StatusCode); + } +} + +public sealed class RegistrationFormBudgetTests(RegistrationBudgetFactory factory) : IClassFixture +{ + [Fact] + public async Task The_general_budget_is_spent_before_the_body_is_read_and_cross_site_posts_spend_nothing() + { + using var client = factory.CreateClient(); + const string peer = "203.0.113.150"; + var fields = new Dictionary { ["identity"] = "x", ["padding"] = new string('x', 17 * 1024) }; + + for (var i = 0; i < 5; i++) + { + using var crossSite = await FormKit.PostAsync(client, fields, origin: "https://evil.example", peer: peer); + Assert.Equal(HttpStatusCode.Forbidden, crossSite.StatusCode); + } + + // Three permits: the over-cap posts spend them (413), the next is 429 whatever its body. + for (var i = 0; i < 3; i++) + { + using var big = await FormKit.PostAsync(client, fields, peer: peer); + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, big.StatusCode); + } + + using var refused = await FormKit.PostAsync(client, fields, peer: peer); + Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode); + } +} diff --git a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs index 04923a6..53b2b5b 100644 --- a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs @@ -328,9 +328,9 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl [InlineData(0, false)] [InlineData(1, true)] [InlineData(18, true)] - [InlineData(30, true)] - [InlineData(31, false)] - public void Difficulty_outside_one_to_thirty_is_refused_at_startup(int difficulty, bool valid) + [InlineData(24, true)] + [InlineData(25, false)] + public void Difficulty_outside_one_to_twenty_four_is_refused_at_startup(int difficulty, bool valid) { var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { Difficulty = difficulty } }; Action validate = () => new StartupConfigurationValidator( -- 2.47.3 From 7bd26e92390cf70b8b7689789766c69f28c4dc22 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 11:07:20 +0000 Subject: [PATCH 4/5] Registration form: text browsers without fetch metadata pass when sessionless; keep a challenge only while live Co-Authored-By: Claude Sonnet 5.5 --- .../RegistrationChallengeService.cs | 14 +++ .../Features/Shared/RequestOriginGuard.cs | 12 +++ src/Bifrost.Web/Program.cs | 7 +- .../RegistrationFormTests.cs | 91 ++++++++++++++++++- 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs index feb3cb9..764810d 100644 --- a/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs +++ b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs @@ -85,6 +85,20 @@ public sealed class RegistrationChallengeService( public bool TryConsumeWaited(string? challenge) => WaitRemaining(challenge) == TimeSpan.Zero && Open(challenge) is { } open && Spend(open); + /// True when the token is ours, unexpired and not yet spent, so a form may carry it into its next attempt. + public bool IsLive(string? challenge) + { + if (Open(challenge) is not { } open) + { + return false; + } + + lock (gate) + { + return !consumed.Contains(open.Id); + } + } + private sealed record OpenChallenge(string Id, int Difficulty, DateTimeOffset ExpiresAt, DateTimeOffset IssuedAt); /// The payload of a challenge that we protected and that has not expired; null otherwise. diff --git a/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs b/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs index 398b3fd..be1f701 100644 --- a/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs +++ b/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs @@ -26,4 +26,16 @@ public static class RequestOriginGuard var fetchSite = request.Headers["Sec-Fetch-Site"]; return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal); } + + /// + /// For the no-script sign-up form only. Text browsers (Lynx, w3m, Links) send neither Origin nor Sec-Fetch-Site, so a + /// request with neither is let through when it carries no Bifrost session cookie: there is no session to replace. + /// Any Origin or Sec-Fetch-Site present is judged exactly as judges it, and a headerless request + /// that does carry a session cookie is refused. + /// + public static bool AllowsFormWithoutFetchMetadata(HttpRequest request, BifrostHostOptions host) => + Allows(request, host) || + (!request.Headers.ContainsKey("Origin") && + !request.Headers.ContainsKey("Sec-Fetch-Site") && + !request.Cookies.Keys.Any(name => name.StartsWith(BifrostCookies.Session, StringComparison.Ordinal))); } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index ac7c8dd..eea7e7b 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -441,7 +441,8 @@ app.MapGet("/auth/bifrost/register", ( }).AllowAnonymous(); // No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead -// (RequestOriginGuard, the same Fetch Metadata check as the vote page). Without it a foreign page could post here with +// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no +// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with // a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent. // Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie. app.MapPost("/auth/bifrost/register/form", async ( @@ -453,7 +454,7 @@ app.MapPost("/auth/bifrost/register/form", async ( IOptions registrationOptions, CancellationToken cancellationToken) => { - if (!RequestOriginGuard.Allows(httpContext.Request, hostOptions.Value)) + if (!RequestOriginGuard.AllowsFormWithoutFetchMetadata(httpContext.Request, hostOptions.Value)) { return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden); } @@ -530,7 +531,7 @@ app.MapPost("/auth/bifrost/register/form", async ( challenges, options, new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field), - outcome.KeepChallenge ? challenge : null, + outcome.KeepChallenge && challenges.IsLive(challenge) ? challenge : null, outcome.Status); static string Truncate(string text) => text.Length <= 256 ? text : text[..256]; diff --git a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs index b271caa..83e0d13 100644 --- a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs @@ -457,8 +457,7 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl { "https://localhost.evil.example", null }, { null, "cross-site" }, { null, "same-site" }, - { null, "none" }, - { null, null } + { null, "none" } }; [Theory] @@ -494,6 +493,94 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode); } + private async Task PostWithAsync(HttpClient client, string html, string identity, string? origin, string? fetchSite, string? cookie) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") + { + Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, nonce: FormKit.Solved(html))) + }; + if (origin is not null) request.Headers.Add("Origin", origin); + if (fetchSite is not null) request.Headers.Add("Sec-Fetch-Site", fetchSite); + if (cookie is not null) request.Headers.Add("Cookie", cookie); + return await client.SendAsync(request); + } + + [Fact] + public async Task A_text_browser_sending_neither_header_is_accepted_only_without_a_session_cookie() + { + const string session = "__Host-bifrost_session=abc"; + using var client = Client(); + + var html = await FormKit.PageAsync(client); + var plain = NewIdentity(); + using (var ok = await PostWithAsync(client, html, plain, null, null, null)) + { + Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode); + Assert.Equal((true, true), Created(plain)); + } + + var second = await FormKit.PageAsync(client); + var withSession = NewIdentity(); + using (var refused = await PostWithAsync(client, second, withSession, null, null, session)) + { + Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); + Assert.False(refused.Headers.Contains("Set-Cookie")); + Assert.False(Exists(withSession)); + } + + // Any header present is judged as before, cookie or not; same-origin with a session passes. + using (var crossNoCookie = await PostWithAsync(client, second, withSession, null, "cross-site", null)) + { + Assert.Equal(HttpStatusCode.Forbidden, crossNoCookie.StatusCode); + } + + using var sameOrigin = await PostWithAsync(client, second, withSession, FormKit.SiteOrigin, null, session); + Assert.Equal(HttpStatusCode.SeeOther, sameOrigin.StatusCode); + } + + [Fact] + public async Task An_identity_error_carries_the_challenge_on_only_while_it_is_live() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var live = FormKit.Hidden(html, "challenge"); + + // Live: kept. + using (var kept = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1"))) + { + Assert.Equal(live, FormKit.Hidden(await kept.Content.ReadAsStringAsync(), "challenge")); + } + + // Garbage: replaced by a fresh live one, never echoed. + var garbage = FormKit.Fields(html, "a!", nonce: "1"); + garbage["challenge"] = "garbage-token"; + using (var replaced = await FormKit.PostAsync(client, garbage)) + { + var page = await replaced.Content.ReadAsStringAsync(); + Assert.DoesNotContain("garbage-token", page); + Assert.True(factory.Services.GetRequiredService().IsLive(FormKit.Hidden(page, "challenge"))); + } + + // Spent: replaced. + var spentPage = await FormKit.PageAsync(client); + using (var spend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, NewIdentity(), nonce: FormKit.Solved(spentPage)))) + { + Assert.Equal(HttpStatusCode.SeeOther, spend.StatusCode); + } + + using (var afterSpend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, "a!", nonce: "1"))) + { + Assert.NotEqual(FormKit.Hidden(spentPage, "challenge"), FormKit.Hidden(await afterSpend.Content.ReadAsStringAsync(), "challenge")); + } + + // Expired: replaced. + factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1)); + using var afterExpiry = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1")); + var expiredPage = await afterExpiry.Content.ReadAsStringAsync(); + Assert.NotEqual(live, FormKit.Hidden(expiredPage, "challenge")); + Assert.True(factory.Services.GetRequiredService().IsLive(FormKit.Hidden(expiredPage, "challenge"))); + } + [Fact] public async Task A_same_origin_post_without_an_Origin_header_is_accepted_on_Sec_Fetch_Site() { -- 2.47.3 From 8ce0e68fe2ddbea82182b4fb6fc66d4cd9a8ad20 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 11:17:24 +0000 Subject: [PATCH 5/5] Docs: origin guard, skip button, identity checks before spend, body cap, difficulty range Co-Authored-By: Claude Sonnet 5.5 --- docs/registration-gates.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/registration-gates.md b/docs/registration-gates.md index a787311..87f5db0 100644 --- a/docs/registration-gates.md +++ b/docs/registration-gates.md @@ -50,12 +50,22 @@ time-lock: - The challenge carries its issue time. A submission that says it waited is accepted only once `FallbackWaitSeconds` (default 30) have passed since issue; earlier, the page comes back saying how many seconds remain and the challenge is - not spent (a fresh challenge would restart the clock). Every other failure re-renders with a fresh challenge, the - consent box unchecked again and the identity kept. `FallbackWaitSeconds` is validated at 5 to under 300, since a challenge - lives 300 seconds. + not spent (a fresh challenge would restart the clock). The same holds for a refused identity (taken, reserved or + malformed): those rules are checked before any permit or challenge is spent, and the still-valid challenge is kept. Every + other failure re-renders with a fresh challenge, the consent box unchecked again (tick it again) and the identity kept. + `FallbackWaitSeconds` is validated at 5 to under 300, since a challenge lives 300 seconds; `Captcha:Difficulty` at 1 to 24. - Fallback submissions have their own, stricter per-address budget (`Limits:FallbackPermits`, default 3 per `FallbackWindow`, default one hour; over it is 429 with Retry-After). - A submission names a nonce or says it waited, never both. +- The page has a second submit button, always enabled, that abandons a solve in progress and submits on the time-lock, so + a slow device is never stuck behind the proof of work. +- `POST /auth/bifrost/register/form` refuses a request that did not come from the site itself (`RequestOriginGuard`: `Origin` + equal to the site origin, else `Sec-Fetch-Site: same-origin`), 403, before it reads the body or spends anything, so a + third-party page cannot register an account and replace a visitor's session. A request with neither header and no + session cookie is allowed, because text browsers send neither and are the people the fallback is for. The body is capped + at 16 KB (413) and the per-address budget is spent before it is read. +- Whether a name is taken is answerable without a proof, bounded by the per-address budget. Names are public (they are + chat localparts), so that leaks nothing. Tradeoff, stated plainly. The time-lock is accessible to everyone who can use a browser: nothing to see, hear, read or solve, no script, no third party. It is also weaker than the proof of work. It costs an attacker wall-clock time, not CPU, -- 2.47.3