# ADR: The one write a stranger can make

Status: accepted, 2026-09-03. The security page has said there is no rate
limiter since it was written. This is not the limiter it meant, and that is the
point.

## In plain words

Creating an account was the only thing a stranger could do that the database kept, and each one costs about 120 ms of CPU to hash the password. Registration is now capped at 120 accounts per sliding hour across the whole site, counted without telling one visitor from another.

What that is worth: a developer sees a limit that works behind the edge because it never needs to know who is asking, and the organization bounds its bill and keeps the container responsive while everything other than registration keeps working.

## Context

Ask a narrow question instead of a broad one: what can somebody with no account
cause this application to keep?

- `GET` everything. Reads, and they cost a query.
- `POST /api/errors/client`. Writes into a ring buffer with fifty slots, each
  one bounded in size, and the ring is the browser's own so a flood of them
  cannot push server errors out of the operator's view. That was fixed in
  1.0.0.64 and it is the shape this record copies.
- `POST /api/auth/register`. Writes a row to `AspNetUsers`, in Azure SQL
  Database, and keeps it.

That is the whole list, and only the last one persists. Bids need an account,
and a bid is keyed on the person and the vehicle together, so a signed-in
visitor can hold at most one row per vehicle. Which means the number of rows
anybody can add to this database is a function of the number of accounts, and
the number of accounts had no ceiling at all.

The rows are the smaller half. Identity hashes a password on the way in, on
purpose, at a cost measured here at about 120 ms of CPU. One container serves
this site. A loop against this one endpoint is a hundred and twenty milliseconds
of somebody else's CPU per request, which is a better attack on this site than
anything else it exposes, and it needs no account, no session and no cleverness.

## What the security page actually argued

It says:

> Behind the edge, the origin sees one address for every visitor, so an
> IP-partitioned limit is a global cap rather than a per-attacker one, and
> because the origin is directly reachable an attacker can bypass the edge and
> forge whatever address they like.

Every word of that is true and none of it is about this. It is an argument
about **partitioning**: any limit that has to tell one visitor from another is
either lying to itself behind the edge or being lied to in front of it.

A limit that does not partition has nothing to forge. It counts registrations,
full stop, and an attacker with ten thousand addresses gets exactly the same
answer as an attacker with one.

That distinction was available the whole time and was not made, because "there
is no rate limiter" was written once and then read as settled. It is the same
species as the three findings in 1.0.0.61 through 1.0.0.63: a sentence that was
correct about the question it was answering, left standing over a question it
was never asked.

## Decision

A sliding hour, counted across the whole site, of 120 registrations.

**Sliding rather than a bucket that resets on the hour.** A bucket hands the
whole allowance back at a known instant, so an attacker takes it twice in two
minutes by arriving either side of the reset. The window holds the times of the
last accepted registrations and ages them out one at a time.

**A refusal is not recorded.** Only an accepted registration takes a slot. If
refusals counted, fifty of them in the fifty-ninth minute would hold the window
open for another hour, and a limiter that never lets go while anybody keeps
knocking is the outage it was built to prevent.

**Checked after the request is read and before the password is hashed.** A
malformed request should not spend the allowance, and a refused one should not
spend the CPU, which is the thing being protected.

**The refusal says nothing with a number in it.** It says the demo is not taking
new accounts and to try in an hour. A reply that reported how much of the
allowance remained would be a counter anybody could poll, and the test asserts
no digit reaches the words.

**120.** This site has never seen more than a handful of registrations in a day
and most of those were its own tests, so the ceiling sits three orders of
magnitude above real use. Nobody meets it who is not trying to.

## What this costs, said plainly

While an attacker is spending the hour's allowance, a real visitor cannot
register either. That is a denial of service, in the small, and it is the trade
being made on purpose: an hour of no new accounts is cheaper than an unbounded
bill and a saturated container, and everything else keeps working. Browsing,
signing in, bidding, and every existing account are untouched, and one test
exists only to say so.

The honest version of the alternative is worth writing down: with no limit, the
same attacker gets the CPU and the rows and nobody is refused, because there is
nothing to refuse them with.

## Alternatives

**A total cap on accounts.** Simpler, and it bounds the bill forever rather than
per hour. It also converts a temporary refusal into a permanent one: fill it
once and this site never takes another account without a deploy. For a portfolio
whose entire purpose is that a stranger can try it, that is the wrong failure to
choose.

**Per-address limiting.** The security page explains why this does not work
here, and it still does not.

**A CAPTCHA.** It would work, and it would put a third party's script on a page
whose whole argument is that a reader can see everything it does.

**Nothing, and say so.** This was the position until today and it was defensible
while the origin held no data. Accounts and a database arrived underneath it.

## What is still owed

The window lives in memory, in one container. A second instance would get its
own, and the site's real ceiling would be 120 times the instance count. That is
correct for what runs today, one container, and it is written here rather than
discovered later. A durable counter belongs with the origin lock, which needs a
paid subscription, and those two are one piece of work.

## Consequences

- The only anonymous write this application accepts has a ceiling, and so, by
  the key on the bids table, does everything else in the database.
- A visitor who has never seen it will never see it.
- The security page's rate limiter paragraph is now half true, and its addendum
  says which half.

The window, read from this build:

```csharp
/// <summary>
/// How many accounts this site will let strangers create in an hour, counted
/// across the whole site rather than per visitor.
///
/// <para>Registration is the only write an anonymous caller can make that
/// persists. Everything else a stranger can reach either reads, or writes into
/// a ring buffer with a fixed number of slots, or needs an account first. So
/// this endpoint is the whole of what an unauthenticated caller can add to the
/// database, and until now there was no ceiling on it: a loop could fill
/// AspNetUsers, and each request through it costs a deliberately expensive
/// password hash, so the cheaper attack is not the rows at all, it is one
/// container's CPU.</para>
///
/// <para>The security page argues that a rate limiter here would not be the
/// control that mattered, and the argument is about partitioning: behind the
/// edge the origin sees one address for every visitor, and because the origin
/// is directly reachable an attacker can bypass the edge and forge whatever
/// address they like, so a per-address limit is a global cap wearing a
/// disguise. That argument is correct and it says nothing about this, because
/// this limit does not partition by anything. There is nothing to forge.</para>
///
/// <para>What it costs is honest and worth stating: while somebody is spending
/// the hour's allowance, a real visitor cannot register either. That is the
/// trade, and it is the right way round for a demo. Browsing, signing in and
/// bidding all keep working, an existing account is untouched, and the ceiling
/// is set far above what this site has ever seen in a day, so the only person
/// who meets it is somebody trying to.</para>
/// </summary>
public sealed class RegistrationLimit(int perHour, Func<DateTimeOffset> now)
{
    /// <summary>
    /// Two an hour would be safer and would be a different product. This site
    /// has never seen more than a handful of registrations in a day, most of
    /// them its own tests, so a hundred and twenty leaves three orders of
    /// magnitude of headroom over real use and still bounds a day of abuse to
    /// something a serverless database does not notice.
    /// </summary>
    public const int DefaultPerHour = 120;

    private static readonly TimeSpan Window = TimeSpan.FromHours(1);

    /// <summary>
    /// When each of the last <see cref="PerHour"/> registrations happened,
    /// oldest first. A sliding window rather than a bucket that resets on the
    /// hour: a bucket lets an attacker take the whole allowance twice in two
    /// minutes by arriving either side of the reset.
    /// </summary>
    private readonly LinkedList<DateTimeOffset> _taken = new();

    private readonly Lock _gate = new();

    public int PerHour { get; } = perHour;

    /// <summary>
    /// Take one of the hour's registrations, or say there are none left. A
    /// refusal costs nothing and is not recorded: a caller who is turned away
    /// has not consumed anything, so a flood of refusals cannot extend the
    /// window it is being refused by.
    /// </summary>
    public bool TryTake()
    {
        DateTimeOffset moment = now();
        lock (_gate)
        {
            while (_taken.Count > 0 && moment - _taken.First!.Value >= Window)
            {
                _taken.RemoveFirst();
            }

            if (_taken.Count >= PerHour)
            {
                return false;
            }

            _taken.AddLast(moment);
            return true;
        }
    }

    /// <summary>
    /// Return the most recent slot, for a registration that was taken and then
    /// refused by Identity before any account existed: a weak password or an
    /// address already registered spends nothing, or a stranger could spend the
    /// hour's allowance with requests that were never going to make an account
    /// (ADR: The one write a stranger can make, addendum).
    /// </summary>
    public void GiveBack()
    {
        lock (_gate)
        {
            if (_taken.Count > 0)
            {
                _taken.RemoveLast();
            }
        }
    }
}
```
*Live from [`api/TheYard.Api/RegistrationLimit.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/RegistrationLimit.cs#L4-L100), region `registration-limit`, read from this build at 1d6b50b.*

## Where it sits

The limit lives in the Api host: `RegistrationLimit` holds the sliding hour, and the register endpoint in `Endpoints/AccountEndpoints.SignIn.cs` calls `TryTake` after reading the request and `GiveBack` when Identity refuses the account. Single responsibility, one reason to change per class, fits here because the window logic sits in its own class and the endpoint only asks it for a slot. The cost is that a real visitor cannot register while somebody else spends the hour's 120 slots. The window also lives in one process's memory, so running a second instance, or adding the origin lock that a durable counter would need, is when this should move into a shared store.

## Files

- [`api/TheYard.Api/RegistrationLimit.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/RegistrationLimit.cs): the window, and why it slides.
- [`api/TheYard.Api/Endpoints/AccountEndpoints.SignIn.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AccountEndpoints.SignIn.cs): where it is taken, between reading the request and hashing the password.
- [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/AuthRegistration.cs): where it is registered with its hourly allowance.
- [`api/TheYard.Tests/RegistrationLimitTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/RegistrationLimitTests.cs): the window, the refusal, and the site that keeps working behind it.
- [`docs/best-practices/SECURITY.md`](https://github.com/SteveStout/TheYard/blob/main/docs/best-practices/SECURITY.md): what is protected, what is not, and what this changes.
- [`docs/decisions/ADR-050-a-password-guess-should-cost-something.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-050-a-password-guess-should-cost-something.md): the other half of making an endpoint cost something.

## Addendum, 2026-09-09: the second write a stranger could make, and the slot that leaked

Two things a review with no context found in this record's own territory,
both fixed in 1.0.0.109. First, the proof (ADR: Same performance, proven)
had a second anonymous write: `POST /api/admin/proof` started a run that
registered two accounts and placed sixteen bids, and a loop of one start a
minute, which its cooldown allowed, spent exactly this record's 120
registrations an hour, so a stranger could close registration by asking the
site to measure itself. Starting a run takes a signed-in visitor now, and the
proof's accounts are made once per process and reused, so the loop that
remains costs nothing this record guards. Second, the slot was taken before
Identity had its say: a request the limiter accepted and Identity then
refused, a short password or an address already registered, kept its slot,
so a stranger could spend the hour's allowance with requests that were never
going to make an account. `RegistrationLimit.GiveBack` returns the slot on
that path, and two tests hold it, one on the window and one through the
endpoint. The rule this record stated stands, and is true again: one write a
stranger can make, and only the ones that happen are counted.