Skip to content

Security and correctness hardening: throttling, cart authorization, settlement races, stale reservations, server-side catalogue - #86

Merged
bgard68 merged 4 commits into
mainfrom
claude/hardening
Aug 25, 2026
Merged

Security and correctness hardening: throttling, cart authorization, settlement races, stale reservations, server-side catalogue#86
bgard68 merged 4 commits into
mainfrom
claude/hardening

Conversation

@bgard68

@bgard68 bgard68 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes the six items from the review, in four commits that can be read independently.

1 · Throttling and cart authorization

There was no rate limiting anywhere in the application. Sign-in, registration, reset, guest lookup and checkout were all unbounded; account lockout caps attempts per account, which stops nothing spread across many accounts and nothing at all on the anonymous endpoints.

Named policies now sit on those surfaces, bound from configuration so a limit can be tightened during an incident without a redeploy. UseRateLimiter runs ahead of authentication, so a flood is rejected before the app validates anything. There is deliberately no global limiter — a catalogue page issues several requests in a burst, so a global cap would throttle browsing while adding nothing.

The partition key is where limiters go wrong: behind a proxy every request carries the proxy's address, callers collapse into one bucket, and the cap trips for everybody. X-Forwarded-For is therefore honoured only when configuration says a trusted proxy is in front. This suite demonstrated the failure mode on itself — the test server sends no remote address, so the API tests shared one partition and passed alone but failed together. The fixture raises its own budgets explicitly; production defaults were not weakened.

Cart.UserId existed and nothing consulted it. Read, update, remove and checkout each took a cart id and used it. CartAccess is now the single rule — guest carts stay open (that is what lets a visitor shop before signing in), claimed carts admit only their owner. Refusals reuse the not-found wording, because a distinct forbidden confirms the id exists. RequestedBy is required, not optional: the compiler made all fourteen call sites state intent.

2 · Settlement races

ConfirmPaymentHandler guarded payment writes by read → check → write. Two concurrent deliveries both pass that check. A duplicate success sent a second receipt; a duplicate failure released the reservation twice, and since the guard only stops the column going negative, the second release consumed stock still held by another order. A late success could mark an order Paid whose stock was already back on sale.

The three payment writes are now compare-and-set, each returning whether it won. MarkPaymentFailedAsync tests its own result inside the transaction and compensates only as the winner, so a redelivered webhook cannot double-release.

This also settles the separate event-id dedupe that was on the list. A dedupe table would restate the same guarantee at a weaker layer — the order's status is the idempotency key, it is durable, and it is the invariant that matters. Adding a table to assert it twice is ceremony.

3 · Stale reservations

An order parked in AwaitingPayment held stock until a provider actively reported failure. A webhook that never arrives held it forever — the engine of the inventory-exhaustion path, since checkout is anonymous by design.

Policy is a plain handler (testable without a timer); scheduling is a BackgroundService that owns nothing but the clock. It takes a fresh scope per tick — the repositories are Scoped, and a singleton holding one would pin a connection for the life of the process. A failed sweep is logged and the loop continues, because a transient blip must not silently stop reclaiming stock until someone restarts the host.

4 · Server-side catalogue

Category and sort were applied in the browser to one fetched page, making PAGE_SIZE a silent ceiling: products fell off every shelf with no error, and "price, low to high" meant "cheapest of the first hundred".

Both moved into the query. Sort maps through a fixed set of clauses and is never interpolated; every clause ends with name so paging is total. refine() is deleted rather than left unused, and its thirteen unit tests are replaced, not dropped — six integration tests against real PostgreSQL now cover the behaviour where it lives, including a sort value carrying a SQL fragment that the whitelist renders inert.

Still open, deliberately: there is no pager. The ceiling is now per shelf rather than per catalogue and ordering is correct across the whole set, but growing past PAGE_SIZE needs pagination in the UI — a feature, not a constant.

Verification

Run locally against real PostgreSQL 16, not inferred:

  • 481 backend tests — 358 unit, 69 integration, 54 API
  • 234 frontend tests; coverage 100% lines and functions
  • dotnet build -warnaserror clean · dotnet format --verify-no-changes clean

Generated by Claude Code

claude added 4 commits August 25, 2026 22:20
Two controls the storefront was missing entirely.

Rate limiting. There was none anywhere in the application, so sign-in,
registration, password reset, guest order lookup and order placement were all
unbounded. Account lockout caps attempts per account, which does nothing
against one attempt each across many accounts and nothing at all on the
anonymous endpoints.

Named policies now sit on the credential surface, on checkout and on guest
lookup, bound from configuration so an operator can tighten one during an
incident without a redeploy. UseRateLimiter runs ahead of authentication so a
flood is rejected before the app spends work validating anything. There is
deliberately no global limiter - a catalogue page issues several requests in a
burst, and a global cap would throttle ordinary browsing while adding nothing.

The partition key is the caller, and getting that wrong is how a limiter
becomes an outage: behind a proxy every request carries the proxy's address,
all callers collapse into one bucket, and the first busy minute trips the cap
for everybody. X-Forwarded-For is therefore read only when configuration says a
trusted proxy is in front, since a client can otherwise forge it and mint
unlimited partitions.

This suite demonstrated that failure mode on itself. The test server supplies
no remote address, so every request shared one partition and the API tests
exhausted a realistic budget between themselves - passing alone, failing
together. The fixture now raises its own budgets explicitly rather than the
production defaults being weakened, and RateLimitingApiTests overrides them
back down to prove rejection, the Retry-After hint, and that browsing still
works while the auth budget is spent.

Cart ownership. Cart.UserId existed and nothing consulted it: read, update,
remove and checkout each took a cart id and used it. Only Guid entropy stood
between a caller and someone else's basket, and a signed-in user's cart was no
better protected than a guest's.

CartAccess is now the single rule - a guest cart stays reachable by anyone
holding its id, which is what lets a visitor shop before signing in, while a
claimed cart admits only its owner. It lives in one place so a new cart
operation cannot ship without the check. A refusal answers with the same
wording as a missing cart, because a distinct forbidden would confirm the id
exists. RequestedBy is a required parameter rather than an optional one with a
default: the compiler made all fourteen call sites state their intent, which is
the point of it being required.

460 backend tests pass locally against PostgreSQL 16; dotnet format clean;
build clean under -warnaserror.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
Payment status was written unconditionally, and ConfirmPaymentHandler guarded
it by reading the order, checking the status, then writing. That is
check-then-act: two concurrent deliveries of the same webhook both read
AwaitingPayment, both pass the check, and both proceed.

The consequences were not symmetric. A duplicate success sent the customer a
second receipt. A duplicate failure ran the compensation twice and decremented
quantity_reserved by twice the order quantity - and because the guard only
stops the column going negative, the second release quietly consumed stock
still held by a different order. An event arriving out of order was worse
still: a late success could mark an order Paid whose stock had already gone
back on sale.

The three payment writes are now compare-and-set. Each carries the statuses it
may apply from and returns whether it won:

  MarkAwaitingPaymentAsync   from Pending
  MarkPaidAsync              from Pending or AwaitingPayment
  MarkPaymentFailedAsync     from Pending or AwaitingPayment

MarkPaymentFailedAsync tests its own result inside the transaction and releases
the reservation only when it won, so compensation runs exactly once no matter
how many times the event is delivered. ConfirmPaymentHandler now treats the
returned bool as the decision rather than the status read it does first, so
only the winner emails a receipt.

CheckoutHandler discards the result with an explicit _ and a comment: those
calls act on an order created moments earlier that is still Pending, so they
cannot decline. The contended path is the webhook, and that is where the answer
is acted on.

This also settles the separate event-id dedupe that was on the list. A dedupe
table would restate the same guarantee at a weaker layer - the order's own
status is the idempotency key, it is durable, and it is the invariant that
actually matters. Adding a table to assert it twice would be ceremony, not
defence.

Three integration tests pin the behaviour: a repeated failure releases stock
once, a late settlement cannot overwrite a failed order, and a stale failure
cannot overwrite a paid one or strip a reservation the customer is owed.

465 backend tests pass locally against PostgreSQL 16.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
Checkout reserves stock the moment an order is placed. When settlement is
asynchronous the order parks in AwaitingPayment and waits for a provider
webhook. Nothing handled the webhook that never arrives - a provider outage, a
dropped delivery, a shopper who closed the tab at the bank's redirect - so that
reservation was held for the life of the database and the only way back was an
administrator editing inventory counts by hand.

That is also the engine of the abuse path: order placement is anonymous by
design, so without a sweep an unauthenticated script could drive availability to
zero across the catalogue and leave it there. Throttling raises the cost of
doing that; this is what makes the damage temporary.

The policy is a plain handler. It finds orders unsettled past a threshold and
releases them through MarkPaymentFailedAsync, which already sets the status and
returns the stock in one transaction, and whose compare-and-set means a webhook
landing during a sweep cannot double-release. PaymentFailed is the honest
description of a settlement that never came, so no new status was needed.

Scheduling is a separate BackgroundService that owns nothing but the clock. It
takes a fresh scope per tick, because the repositories are Scoped and a
singleton holding one would pin a connection for the life of the process. It
waits a full interval before the first pass, since startup is the worst moment
to add database work. A failed sweep is logged and the loop continues: a
transient database blip must not silently stop reclaiming stock until someone
restarts the host. Cancellation during shutdown ends the loop quietly rather
than surfacing as an error, and is checked between orders so a large batch stops
cleanly.

Every knob is configuration - window, interval, batch size, and an off switch
for a host that should not run background work. The batch cap means a backlog is
worked through over several passes rather than one long transaction.

Seven tests cover the policy against a fake clock, so none of them wait: stale
orders release, orders inside the window are left alone, settled orders are
never swept, a second sweep is a no-op rather than a second decrement, the batch
cap holds, the oldest go first, and a cancelled sweep stops.

475 backend tests pass locally against PostgreSQL 16.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
Category and sort were applied in the browser to a single fetched page. That
made PAGE_SIZE a hidden ceiling on the whole catalogue: past it, products fell
off the end of every category shelf without any error, and a sort ordered only
whatever happened to be on that page - so "price, low to high" meant "the
cheapest of the first hundred". The failure was silent in both directions,
which is what made it worth fixing rather than raising a number.

WidgetQuery now carries Category and Sort. Category is a second AND against
name and sku, so a search inside a category means both rather than either.
Sort is mapped through a fixed set of order-by clauses and never interpolated,
which is the only safe way to put a caller's string near an ORDER BY; an
unrecognised value falls back to the default rather than reaching the database.
Every clause ends with name so paging is total - without a tiebreak, rows with
equal prices can appear on two pages and never on a third.

Featured moved with it. It used to push sold-out items to the end of the page
in the browser, which quietly meant "in stock on this page first"; the SQL
applies it across the whole matching set.

The listing and its count now share one parameter object, so the two can no
longer drift and report a total the grid never shows.

refine() and matchesCategory() are deleted rather than left unused - dead code
that still looks authoritative is worse than none. Their thirteen unit tests
are replaced, not dropped: the behaviour is SQL now, so six integration tests
against real PostgreSQL cover category narrowing, search-and-category together,
both price directions, featured ordering, and an unrecognised sort carrying a
SQL fragment, which the whitelist renders inert.

Two page tests changed with the contract: sorting is asserted as a request
carrying the choice rather than a client-side reorder, and a new one pins that
a category reaches the API as ?category=.

Remaining, and deliberately not in this change: there is still no pager, so one
request shows one page. The ceiling is now per shelf rather than across the
catalogue, and ordering is correct over the whole set, but a catalogue past
PAGE_SIZE needs pagination in the UI - a feature, not a constant.

481 backend tests and 234 frontend tests pass; frontend coverage 100% lines and
functions; build clean under -warnaserror; dotnet format clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
@bgard68
bgard68 merged commit 4051fb9 into main Aug 25, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants