Skip to content

Commit e7eb105

Browse files
ratelimits: stricter() should always prefer denied decisions (#8674)
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.
1 parent f05103a commit e7eb105

File tree

2 files changed

+79
-9
lines changed

2 files changed

+79
-9
lines changed

ratelimits/limiter.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,17 +238,27 @@ func prepareBatch(txns []Transaction) ([]Transaction, []string, error) {
238238
return transactions, bucketKeys, nil
239239
}
240240

241-
func stricter(existing *Decision, incoming *Decision) *Decision {
242-
if existing.retryIn == incoming.retryIn {
243-
if existing.remaining < incoming.remaining {
244-
return existing
241+
func stricter(a, b *Decision) *Decision {
242+
switch {
243+
case a.allowed != b.allowed:
244+
// Denied is always stricter than allowed.
245+
if !a.allowed {
246+
return a
245247
}
246-
return incoming
247-
}
248-
if existing.retryIn > incoming.retryIn {
249-
return existing
248+
return b
249+
case a.retryIn != b.retryIn:
250+
// Longer wait is stricter.
251+
if a.retryIn > b.retryIn {
252+
return a
253+
}
254+
return b
255+
default:
256+
// Fewer remaining is stricter.
257+
if a.remaining < b.remaining {
258+
return a
259+
}
260+
return b
250261
}
251-
return incoming
252262
}
253263

254264
// BatchSpend attempts to deduct the costs from the provided buckets'

ratelimits/limiter_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,63 @@ func TestRateLimitError(t *testing.T) {
610610
})
611611
}
612612
}
613+
614+
func TestStricterDeniedBeatsAllowed(t *testing.T) {
615+
t.Parallel()
616+
617+
clk := clock.NewFake()
618+
l := newInmemTestLimiter(t, clk)
619+
ctx := context.Background()
620+
621+
// Limit A, our fast limit, permits 2 requests per second.
622+
limitA := &Limit{
623+
Burst: 2,
624+
Count: 2,
625+
Period: config.Duration{Duration: time.Second},
626+
Name: NewRegistrationsPerIPAddress,
627+
}
628+
limitA.precompute()
629+
630+
// Limit B, our slow limit, permits 2 requests per hour. An allowed decision
631+
// from this limit will have a retryIn up to 30 minutes, far exceeding any
632+
// retryIn from the denied limit A.
633+
limitB := &Limit{
634+
Burst: 2,
635+
Count: 2,
636+
Period: config.Duration{Duration: time.Hour},
637+
Name: NewRegistrationsPerIPv6Range,
638+
}
639+
limitB.precompute()
640+
641+
bucketKeyA := "limitA:testkey"
642+
bucketKeyB := "limitB:testkey"
643+
644+
// Exhaust limit A's bucket completely.
645+
txnA2, err := newTransaction(limitA, bucketKeyA, 2)
646+
test.AssertNotError(t, err, "Txn should be valid")
647+
d, err := l.Spend(ctx, txnA2)
648+
test.AssertNotError(t, err, "Should not error")
649+
test.Assert(t, d.allowed, "Initial spend should be allowed")
650+
test.AssertEquals(t, d.remaining, int64(0))
651+
652+
// Spend 1 from limit B so it's reduced to 1 remaining.
653+
txnB1, err := newTransaction(limitB, bucketKeyB, 1)
654+
test.AssertNotError(t, err, "Txn should be valid")
655+
d, err = l.Spend(ctx, txnB1)
656+
test.AssertNotError(t, err, "Should not error")
657+
test.Assert(t, d.allowed, "Initial spend should be allowed")
658+
test.AssertEquals(t, d.remaining, int64(1))
659+
660+
// Now batch, limit A should deny (0 remaining), limit B should allow (1
661+
// remaining) but with a large retryIn (~30 minutes).
662+
txnA1, err := newTransaction(limitA, bucketKeyA, 1)
663+
test.AssertNotError(t, err, "Txn should be valid")
664+
txnB1, err = newTransaction(limitB, bucketKeyB, 1)
665+
test.AssertNotError(t, err, "Txn should be valid")
666+
667+
d, err = l.BatchSpend(ctx, []Transaction{txnA1, txnB1})
668+
test.AssertNotError(t, err, "Should not error")
669+
670+
// The batch MUST be denied because limit A denied the request.
671+
test.Assert(t, !d.allowed, "Batch should be denied when any limit denies")
672+
}

0 commit comments

Comments
 (0)