Skip to content

Feature: per recipient email limits - #38

Closed
johnhooks wants to merge 8 commits into
feature/reusable-attempt-locksfrom
feature/per-recipient-email-limits
Closed

Feature: per recipient email limits#38
johnhooks wants to merge 8 commits into
feature/reusable-attempt-locksfrom
feature/per-recipient-email-limits

Conversation

@johnhooks

Copy link
Copy Markdown
Collaborator

Stacked on feature/reusable-attempt-locks. A signed-in account could trigger unlimited change-email verification mail to any address it typed, and the identities our limits count against were easy to fake — email aliases split ledgers, and a spoofed forwarding header minted a fresh IP per request. This branch caps the sending durably and makes both identities trustworthy.

Changes

  • Change-email initiations are capped at 3 per account under the escalating lock ladder, with a dedicated email_change lock so the cap and its refusal message are independent of the sign-in code budget. Admin-of-other keeps its bypass.
  • Email identities are canonicalized before counting, so plus-tags, dots, and case variants of one mailbox share one ledger.
  • Client IP resolution has one owner with an explicit trust model: REMOTE_ADDR unless the operator names the header their proxy overwrites, read so that caller-supplied entries carry no weight. Rate limits, the activity log, and the audit log now agree on who the caller is.
  • The burst rate limiter is rebuilt as a plain windowed counter, atomic under a persistent object cache, with its dead reservation API removed. Call sites are unchanged.
  • Time is injected rather than read from the wall clock, so cooldown and window behavior is tested by stepping a clock instead of sleeping.

Enumeration-safe routes keep their uniform responses; refused sends are skipped silently. Details in docs/locks.md and docs/change-email.md.

Forwarding headers gain one entry per hop, each appending the address it
received from, so the left-most entry is whatever the client sent. Reading it
let any caller behind a configured proxy mint a fresh identity per request and
walk past every per-IP limit.

The scan now runs right to left, skipping private and reserved ranges so an
internal load balancer does not become everyone's bucket.

The activity log shares this resolution instead of accepting any forwarding
header present, so audit rows record an address the site can vouch for.
Three places worked out who was calling, and they disagreed. The WorkOS audit
log saw only REMOTE_ADDR, so every event behind a CDN recorded the edge node,
and the activity log walked forwarding headers with no trust configuration at
all.

ClientIp owns it now and is injected where it is used, so nothing borrows the
rate limiter to answer a question about the request. Login no longer holds a
limiter it never counted with.
Locks decide on cooldowns measured in hours, so exercising a rollover meant
either waiting or reaching for a global filter. Clock is injected instead:
Registry hands one to every lock it builds, and the garbage collector, profile
panel and CLI take their own.

Replaces the workos_lock_current_time filter, which shipped in no release. The
workos_locks filter now also passes the clock, since a third-party lock needs
one to construct.
The limiter tracked two cache keys per subject, a first-seen stamp and a
count, measured its window from first use, and carried a recovery branch for
when one key outlived the other. Its transient fallback stored an array, so
even the non-atomic path was a read-modify-write over a structure.

Counting now happens against one key with the window epoch built into it, so
expiry is arithmetic and a stale counter can only reach a window that never
comes round again. The value is a plain integer, which is what lets the
object-cache store use wp_cache_incr and be genuinely atomic rather than
atomic-ish. Storage sits behind CounterStore, and the clock is injected, so
window rollover is testable without waiting for one.

attempt() keeps its signature, so all nineteen call sites are unchanged and
the old class goes. Reservations arrive with the core and are wired to the
delivery paths next.
The initiate route had burst limits but no durable lock. Self-service
requests now check and record one user-backed otp_send attempt per
initiation, however many messages it sends or addresses it names, so a
signed-in account cannot spray recipients through our sending domain.
The capability-gated admin path keeps its bypass.
for_email() now keys unowned addresses on their canonical form, so
re-spelling an inbox (alice+2@, dotted gmail) no longer restarts any
counter on any lock. Addresses owned by a WP user still resolve to the
account; the subject value stays canonical either way, so counters
recorded before the account existed are found and folded in whichever
spelling the user registered with.

Locks now take an AddressCanonicalizer, and the workos_locks filter
passes it as a fourth argument for constructing custom locks.
reserve()/release()/consume(), the Reservation handle and the
CounterStore decrement primitive were ported for a delivery ceiling
that shipped as attempt locks instead, leaving them with tests but no
production caller. Docblocks referencing the never-built
OutboundEmailRateLimiter now point at the locks that actually sit
behind the limiter.

Also reframes the lock Store contract's durability as the
implementation's defining trade rather than a hard rule — a
cache-backed store swapped in via workos_lock_store is a legitimate
operator choice where flushes and eviction are controlled — and syncs
the docs with the branch: the workos_locks filter's new fourth
argument, and change-email's durable initiate cap.
Initiations drew on otp_send, whose label and refusal message describe
one-time codes and whose budget is shared with sign-in verification
sends — a user who burned strikes there was barred from changing their
email, and the 429 talked about codes that were never involved.

A dedicated email_change lock now caps self-service initiations at 3
per account with the standard ladder. Keyed per requesting account,
never per address, since the destination is whatever the caller typed.
The admin-direct path keeps its bypass. Lock tests import classes
instead of spelling out FQCNs inline.
new MfaChallengeLock( $this->store, $this->clock, $this->addresses ),
new SignupLock( $this->store, $this->clock, $this->addresses ),
new TokenGuessLock( $this->store, $this->clock, $this->addresses ),
new EmailChangeLock( $this->store, $this->clock, $this->addresses ),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge fan of this. When I added the clock and email canonicalizer, I was only thinking they would be DI injected, not manually wired like this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need these filterable? I don't think so.

Also, I don't see the reason we need the Registry for this. We should just DI resolve the locks, there is a filter to replace them when called already.

Comment on lines +69 to +72
$plus = strpos( $local, '+' );
if ( false !== $plus ) {
$local = substr( $local, 0, $plus );
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can strip + everywhere. It's a legal character in the local part and not every provider treats it as a tag - on a lot of corporate and self-hosted mail business+brian@corp.com is just a different mailbox with a different person behind it. Stripping it means those two end up sharing a lock ladder, so one of them can burn through the other's OTP and reset allowance.

The dot handling right below this already does the careful version — only collapsing for the providers known to ignore them. I'd do the same for +, or at least put it behind a filter so we can turn it off for domains where it isn't a tag.

This scenario may not happen out in the open as it's pretty specific. I just wanted to call it out.

@johnhooks

Copy link
Copy Markdown
Collaborator Author

close in favor of #39

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