Skip to content

ratelimits: stricter() should always prefer denied decisions#8674

Merged
jsha merged 1 commit intomainfrom
fix-ratelimits-stricter
Mar 17, 2026
Merged

ratelimits: stricter() should always prefer denied decisions#8674
jsha merged 1 commit intomainfrom
fix-ratelimits-stricter

Conversation

@beautifulentropy
Copy link
Copy Markdown
Member

@beautifulentropy beautifulentropy commented Mar 12, 2026

In ratelimits.BatchSpend(), stricter() selects the most restrictive decision across all rate limits in a batch. It compares decisions solely by retryIn, assuming a longer wait is always stricter, and never checks the allowed field. An allowed decision with a high retryIn beats a denied decision with a low retryIn, causing the batch to return allowed: true despite one or more limits denying the request.

This can occur at NewOrder time via checkNewOrderLimits(), which batches:

  • NewOrdersPerAccount (check-and-spend, emissionInterval=36s)
  • FailedAuthorizationsPerDomainPerAccount (check-only, emissionInterval=12min)
  • CertificatesPerDomain (check-only, emissionInterval=3.36h)
  • CertificatesPerFQDNSet (check-only, emissionInterval=33.6h)

A subscriber who exhausts their 300 new order quota gets a denied retryIn of at most 36s. If any check-only limit in the same batch has exactly 1 token remaining at request time, that last token produces an allowed decision with retryIn equal to the limit's full emission interval, all of which dwarf 36s. Because check-only transactions never deduct from the bucket, the token is never consumed and the same oversized retryIn wins on every subsequent request. Orders can pile up well past the 300 limit until countCertificateIssued() runs a separate spend-only batch after issuance.

This can also occur for IPv6 clients at NewAccount time via checkNewAccountLimits(), which batches:

  • NewRegistrationsPerIPAddress (check-and-spend,emissionInterval = 18min)
  • NewRegistrationsPerIPv6Range (check-and-spend,emissionInterval = 21.6s)

These are both check-and-spend with the same 3 hour period. The emission intervals differ (18min vs 21.6s), so the bug is technically possible here too. For IPv6, the per-IP limit (10) will almost always exhaust before the /48 range limit (500), so the reverse scenario (per-range denied, per-IP allowed) is unlikely but not impossible if many IPs in the same /48 are registering. If NewRegistrationsPerIPv6Range denies (retryIn = 21.6s) while NewRegistrationsPerIPAddress allows with 1 remaining (retryIn = 18min), stricter() picks the allowed decision. Both transactions are check-and-spend, so the request that sneaks through deducts from the per-IP bucket, exhausting it. After that, both limits deny and the bug no longer triggers, unlike NewOrder.

Restructure stricter() so denied decisions are always picked over allowed ones regardless of retryIn. The retryIn and remaining comparisons now serve only as tiebreakers within the same allowed/denied category.

@beautifulentropy beautifulentropy marked this pull request as ready for review March 12, 2026 21:20
@beautifulentropy beautifulentropy requested a review from a team as a code owner March 12, 2026 21:20
@beautifulentropy beautifulentropy requested a review from jsha March 17, 2026 14:46
@jsha jsha merged commit e7eb105 into main Mar 17, 2026
52 of 54 checks passed
@jsha jsha deleted the fix-ratelimits-stricter branch March 17, 2026 21:03
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.

3 participants