Registration form: origin guard, skip button, identity checks before spending, caps and error links
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
185cfcd0e7
commit
d47f1d5e9d
11 changed files with 376 additions and 56 deletions
|
|
@ -64,6 +64,7 @@ and nothing else. An attacker with many addresses passes it at the price of the
|
|||
registration buys little: the account is `Authenticated`, in Users, with no vote and no member surface, and the other
|
||||
gates (consent, verification once built, reserved names, suspension) still apply. If fallback registrations are abused,
|
||||
raise the wait or lower the budget; do not add a visual or audio puzzle, which excludes the people the fallback is for.
|
||||
|
||||
## Consent receipt
|
||||
|
||||
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ public sealed class RegistrationOptions
|
|||
|
||||
public const int MinDifficulty = 1;
|
||||
|
||||
public const int MaxDifficulty = 30;
|
||||
public const int MaxDifficulty = 24;
|
||||
|
||||
public const int MinFallbackWaitSeconds = 5;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ public sealed partial class BifrostIdentityService(
|
|||
BifrostDbContext dbContext,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<UserAccount> RegisterNativeAsync(
|
||||
string identity,
|
||||
string? displayName,
|
||||
int noticeVersion,
|
||||
CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// The one rule set for a new native identity: well formed, not reserved, not taken. Returns the normalized identity or
|
||||
/// throws <see cref="BifrostIdentityException"/>. Spends nothing; the unique index at insert stays the race backstop.
|
||||
/// </summary>
|
||||
public async Task<string> CheckAvailableAsync(string identity, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedIdentity = NormalizeIdentity(identity);
|
||||
if (IsReserved(normalizedIdentity))
|
||||
|
|
@ -24,14 +24,22 @@ public sealed partial class BifrostIdentityService(
|
|||
throw new BifrostIdentityException("That Bifrost identity is reserved.");
|
||||
}
|
||||
|
||||
var exists = await dbContext.UserAccounts.AnyAsync(
|
||||
x => x.NormalizedBifrostIdentity == normalizedIdentity,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
if (await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity, cancellationToken))
|
||||
{
|
||||
throw new BifrostIdentityException("That Bifrost identity is already registered.");
|
||||
}
|
||||
|
||||
return normalizedIdentity;
|
||||
}
|
||||
|
||||
public async Task<UserAccount> RegisterNativeAsync(
|
||||
string identity,
|
||||
string? displayName,
|
||||
int noticeVersion,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedIdentity = await CheckAvailableAsync(identity, cancellationToken);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var publicIdentity = ToPublicIdentity(normalizedIdentity);
|
||||
var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName)
|
||||
|
|
@ -66,10 +74,31 @@ public sealed partial class BifrostIdentityService(
|
|||
NoticeVersion = noticeVersion,
|
||||
AcceptedAtUtc = now
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Only a lost race on the unique index is the caller's mistake; anything else is a fault and propagates.
|
||||
if (!await IdentityTakenAsync(normalizedIdentity))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new BifrostIdentityException("That Bifrost identity is already registered.");
|
||||
}
|
||||
|
||||
return userAccount;
|
||||
}
|
||||
|
||||
// A lost race on the unique index: the tracked insert is dropped and the identity is read fresh.
|
||||
private async Task<bool> IdentityTakenAsync(string normalizedIdentity)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
return await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity);
|
||||
}
|
||||
|
||||
public async Task EnsureIdentityAsync(
|
||||
UserAccount userAccount,
|
||||
string preferredIdentity,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public enum RegistrationField { None, Identity, Consent }
|
|||
|
||||
/// <summary>
|
||||
/// Either a signed-in <see cref="Account"/>, or a <see cref="Refusal"/> to return as is (the rate limits), or a rejection
|
||||
/// with a status and message. <see cref="WaitRemaining"/> is set when the time-lock was not over; the challenge is then still unspent.
|
||||
/// with a status and message. <see cref="KeepChallenge"/> is set when the challenge was not spent and a retry needs only a correction, so the form should carry it on (the time-lock clock must not restart).
|
||||
/// </summary>
|
||||
public sealed record RegistrationOutcome(
|
||||
UserAccount? Account,
|
||||
|
|
@ -27,16 +27,17 @@ public sealed record RegistrationOutcome(
|
|||
int Status,
|
||||
string? Message,
|
||||
RegistrationField Field,
|
||||
TimeSpan? WaitRemaining)
|
||||
bool KeepChallenge)
|
||||
{
|
||||
public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, TimeSpan? wait = null) =>
|
||||
new(null, null, status, message, field, wait);
|
||||
public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, bool keepChallenge = false) =>
|
||||
new(null, null, status, message, field, keepChallenge);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// The one registration gate, run by the JSON endpoint and the form alike after each has spent the general address budget.
|
||||
/// Order: consent, one proof only, the identity rules (which spend nothing), 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, and a refusal before the challenge step spends neither the challenge nor a fallback permit.
|
||||
/// </summary>
|
||||
public sealed class NativeRegistration(
|
||||
BifrostIdentityService identities,
|
||||
|
|
@ -45,11 +46,6 @@ public sealed class NativeRegistration(
|
|||
{
|
||||
public async Task<RegistrationOutcome> 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)
|
||||
{
|
||||
|
|
@ -65,6 +61,15 @@ public sealed class NativeRegistration(
|
|||
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Send a nonce or waited, not both.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await identities.CheckAvailableAsync(attempt.Identity ?? "", cancellationToken);
|
||||
}
|
||||
catch (BifrostIdentityException error)
|
||||
{
|
||||
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity, keepChallenge: true);
|
||||
}
|
||||
|
||||
var failed = RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Registration challenge failed.");
|
||||
if (hasNonce)
|
||||
{
|
||||
|
|
@ -85,13 +90,13 @@ public sealed class NativeRegistration(
|
|||
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);
|
||||
$"Too early: {seconds} more second{(seconds == 1 ? "" : "s")} to wait after the page opened. Your challenge is still good; submit again then.",
|
||||
keepChallenge: true);
|
||||
}
|
||||
|
||||
if (rateLimit.RefuseFallback(http) is { } fallbackRefused)
|
||||
{
|
||||
return new(null, fallbackRefused, 0, null, RegistrationField.None, null);
|
||||
return new(null, fallbackRefused, 0, null, RegistrationField.None, false);
|
||||
}
|
||||
|
||||
if (!challenges.TryConsumeWaited(attempt.Challenge))
|
||||
|
|
@ -108,7 +113,7 @@ public sealed class NativeRegistration(
|
|||
{
|
||||
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);
|
||||
return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, false);
|
||||
}
|
||||
catch (BifrostIdentityException error)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public static class RegistrationPage
|
|||
var BATCH = 256;
|
||||
var busy = false;
|
||||
var solved = false;
|
||||
var skipped = false;
|
||||
|
||||
function zeroBits(bytes) {
|
||||
var bits = 0;
|
||||
|
|
@ -43,7 +44,7 @@ public static class RegistrationPage
|
|||
|
||||
async function solve(challenge, difficulty) {
|
||||
var encoder = new TextEncoder();
|
||||
for (var start = 0; ; start += BATCH) {
|
||||
for (var start = 0; !skipped; start += BATCH) {
|
||||
var tries = [];
|
||||
for (var n = start; n < start + BATCH; n++) {
|
||||
tries.push(crypto.subtle.digest("SHA-256", encoder.encode(challenge + ":" + n)));
|
||||
|
|
@ -54,15 +55,26 @@ public static class RegistrationPage
|
|||
}
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 0); });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (event.submitter && event.submitter.id === "skip") {
|
||||
// The way out of a slow solve: stop it and let this submit go on the time-lock path.
|
||||
skipped = true;
|
||||
solved = true;
|
||||
form.elements.nonce.value = "";
|
||||
form.elements.waited.disabled = false;
|
||||
status.textContent = "Skipping the automatic check.";
|
||||
return;
|
||||
}
|
||||
if (solved) { return; }
|
||||
event.preventDefault();
|
||||
if (busy) { return; }
|
||||
busy = true;
|
||||
status.textContent = "Checking, a few seconds";
|
||||
status.textContent = "Checking. This can take longer on a slow device; press Skip the automatic check to wait " + form.getAttribute("data-wait") + " seconds instead.";
|
||||
solve(form.elements.challenge.value, parseInt(form.getAttribute("data-difficulty"), 10)).then(function (nonce) {
|
||||
if (skipped || nonce === null) { return; }
|
||||
form.elements.nonce.value = nonce;
|
||||
form.elements.waited.disabled = true;
|
||||
solved = true;
|
||||
|
|
@ -71,7 +83,7 @@ public static class RegistrationPage
|
|||
}, 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.";
|
||||
status.textContent = "The automatic check did not run in this browser. Press Skip the automatic check and wait as described below.";
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -84,7 +96,13 @@ public static class RegistrationPage
|
|||
var summary = new StringBuilder();
|
||||
if (model.Error is not null)
|
||||
{
|
||||
summary.Append($"""<div id="errors" role="alert" tabindex="-1"><strong>Registration did not go through.</strong><p>{E(model.Error)}</p></div>""");
|
||||
var link = model.ErrorField switch
|
||||
{
|
||||
RegistrationField.Identity => """<ul><li><a href="#identity">Identity name</a></li></ul>""",
|
||||
RegistrationField.Consent => """<ul><li><a href="#consent">I have read this notice and consent</a></li></ul>""",
|
||||
_ => ""
|
||||
};
|
||||
summary.Append($"""<div id="errors" role="alert" tabindex="-1"><strong>Registration did not go through.</strong><p>{E(model.Error)}</p>{link}</div>""");
|
||||
}
|
||||
|
||||
string Invalid(RegistrationField field) => model.Error is not null && model.ErrorField == field ? " aria-invalid=\"true\" aria-describedby=\"errors\"" : "";
|
||||
|
|
@ -109,6 +127,7 @@ public static class RegistrationPage
|
|||
input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; margin-top: 4px; padding: 0 10px; border: 1px solid #8a7d73; border-radius: 6px; background: #0d0b0a; color: #fff8ef; font: inherit; }
|
||||
input[type=checkbox] { width: 22px; height: 22px; margin: 0 8px 0 0; vertical-align: middle; accent-color: #c43e4f; }
|
||||
label.check { display: flex; align-items: center; margin-top: 14px; }
|
||||
button.secondary { margin-left: 10px; background: #2a2522; border: 1px solid #8a7d73; }
|
||||
button { margin-top: 20px; min-height: 46px; padding: 0 18px; border: 0; border-radius: 6px; background: #c43e4f; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
|
||||
:focus-visible { outline: 3px solid #ffd27a; outline-offset: 2px; }
|
||||
#errors { margin-top: 18px; padding: 10px 14px; border: 2px solid #ff9aa6; border-radius: 6px; color: #ffc2c9; }
|
||||
|
|
@ -124,7 +143,7 @@ public static class RegistrationPage
|
|||
<h1>Create a Bifrost account</h1>
|
||||
<p>A new account can chat. It has no vote and opens no member or governance surface.</p>
|
||||
{{summary}}
|
||||
<form id="register" method="post" action="/auth/bifrost/register/form" data-difficulty="{{model.Difficulty}}">
|
||||
<form id="register" method="post" action="/auth/bifrost/register/form" data-difficulty="{{model.Difficulty}}" data-wait="{{wait}}">
|
||||
<fieldset>
|
||||
<legend>Your account</legend>
|
||||
<label for="identity">Identity name</label>
|
||||
|
|
@ -143,9 +162,10 @@ public static class RegistrationPage
|
|||
<input type="hidden" name="nonce" value="" />
|
||||
<input type="hidden" name="waited" value="true" />
|
||||
<p id="status" aria-live="polite"></p>
|
||||
<button type="submit">Create account</button>
|
||||
<button type="submit" id="create">Create account</button>
|
||||
<button type="submit" id="skip" class="secondary">Skip the automatic check and wait {{wait}} seconds instead</button>
|
||||
</form>
|
||||
<p class="hint" id="wait-note">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.</p>
|
||||
<p class="hint" id="wait-note">Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick the box again before you submit. Without the automatic check only a few registrations per hour are accepted from one address.</p>
|
||||
<p><small>After registering, Bifrost returns you to {{E(model.ReturnPath)}}.</small></p>
|
||||
</main>
|
||||
<script>{{Script}}</script>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<BifrostHostOptions> hostOptions,
|
||||
IOptions<RegistrationOptions> 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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() 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];
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const script = scripts[0][1];
|
|||
const challenge = html.match(/<input type="hidden" name="challenge" value="([^"]*)"/)[1];
|
||||
const difficulty = html.match(/data-difficulty="(\d+)"/)[1];
|
||||
|
||||
async function run(name, { withCrypto, submits }) {
|
||||
async function run(name, { withCrypto, submits, skipAfter = false }) {
|
||||
const statuses = [];
|
||||
let handler = null;
|
||||
let submitted = 0;
|
||||
|
|
@ -21,7 +21,7 @@ async function run(name, { withCrypto, submits }) {
|
|||
const done = new Promise((resolve) => (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));
|
||||
|
|
|
|||
|
|
@ -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<HttpResponseMessage> PostAsync(HttpClient client, Dictionary<string, string> 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<HttpResponseMessage> PostAsync(HttpClient client, Dictionary<string, string> 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<string, string> 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("<button type=\"submit\" id=\"skip\"[^>]*>Skip the automatic check and wait \\d+ seconds instead</button>", html);
|
||||
Assert.DoesNotContain("a few seconds", html);
|
||||
Assert.DoesNotContain("nothing is lost", html);
|
||||
Assert.Contains("tick the box again", html);
|
||||
Assert.DoesNotContain("<noscript", html);
|
||||
Assert.DoesNotContain("http://", html);
|
||||
Assert.DoesNotContain("https://", html);
|
||||
foreach (Match url in Regex.Matches(html, "(?:src|href|action)=\"([^\"]*)\""))
|
||||
{
|
||||
Assert.StartsWith("/", url.Groups[1].Value);
|
||||
Assert.DoesNotMatch("^//", url.Groups[1].Value);
|
||||
Assert.Matches("^(/(?!/)|#)", url.Groups[1].Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +253,7 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl
|
|||
Assert.Matches("role=\"alert\"", again);
|
||||
Assert.Contains("requires accepting the training notice", again);
|
||||
Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
|
||||
Assert.Contains("<a href=\"#consent\">", again);
|
||||
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\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("<a href=\"#identity\">", 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<string?, string?> 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<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>()
|
||||
.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<InvalidOperationException>(() => Validate(options)).Message);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationValidationBudgetFactory : RegistrationFormFactory
|
||||
{
|
||||
protected override string FallbackPermits => "2";
|
||||
}
|
||||
|
||||
public sealed class RegistrationValidationBudgetTests(RegistrationValidationBudgetFactory factory) : IClassFixture<RegistrationValidationBudgetFactory>
|
||||
{
|
||||
[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<RegistrationBudgetFactory>
|
||||
{
|
||||
[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<string, string> { ["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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue