Skip to content

fix: let a resubscribing central recover from the announce rate limit - #1669

Open
Chessing234 wants to merge 1 commit into
permissionlesstech:mainfrom
Chessing234:fix/subscription-limiter-never-recovers
Open

fix: let a resubscribing central recover from the announce rate limit#1669
Chessing234 wants to merge 1 commit into
permissionlesstech:mainfrom
Chessing234:fix/subscription-limiter-never-recovers

Conversation

@Chessing234

Copy link
Copy Markdown
Contributor

Found by reading BLESubscriptionAnnounceLimiter, not from an issue.

The bug

decision(for:now:) rebuilds the entry on a rejected attempt with
lastAnnounceTime: now:

states[centralID] = State(
    lastAnnounceTime: now,          // <- refreshed even though nothing was announced
    attemptCount: newAttemptCount,
    currentBackoffSeconds: newBackoff
)

That field is the reference for both comparisons in this type — the backoff
check (now.timeIntervalSince(existing.lastAnnounceTime)) and
pruneStaleEntries. Refreshing it on rejection means the elapsed time is reset
by the very attempt that was refused, so for a central that keeps resubscribing:

  • elapsed never grows past currentBackoffSeconds, which has meanwhile been
    doubled up to the 30 s cap, so every subsequent attempt is refused;
  • attemptCount climbs past bleSubscriptionRateLimitMaxAttempts (5), so
    suppressAnnounce latches true;
  • and pruneStaleEntries never fires either, because it measures staleness from
    the same refreshed timestamp.

The result is that the central is suppressed for as long as it keeps trying, with
no path back.

Why it matters beyond the attack case

The limiter is the BCH-01-004 enumeration defence, and against a hammering
attacker the behaviour looks like a feature. But a legitimate central with a
flapping BLE link resubscribes exactly the same way — didSubscribeTo fires on
every reconnect. Once it crosses 5 attempts we stop announcing to it
(BLEService+LinkLayerPeripheralRole.swift:151-156 returns early), so we stay
invisible to that peer until the app restarts or it stops reconnecting for a
full window
— and it cannot stop reconnecting, because the link is flapping.

The fix

Keep lastAnnounceTime at the last allowed announce. That is what
TransportConfig already documents it as — "Minimum interval between announces
per central" — and it makes the backoff mean what it says.

Escalation is deliberately unchanged: attemptCount still climbs, the backoff
still doubles to the 30 s cap, and suppressAnnounce still engages at 5
attempts. The only change is that elapsed time is now measured from a fixed
point, so a central is re-admitted once the capped backoff has genuinely passed
— i.e. at most one announce per 30 s for someone hammering, instead of none ever.

Test plan

  • swift test --parallel2020 tests in 219 suites passed. Baseline on
    main: 2018 in 219, also green. The delta is exactly the 2 tests added here.
  • Both new tests were written before the fix and confirmed to fail against
    unmodified main:
    Expectation failed: (admittedAt → nil) != nil — after 60 s of one
    resubscribe per second the central was still
    .rateLimited(backoffSeconds: 30.0, attemptCount: 120, suppressAnnounce: true).
  • Teeth re-checked after the fix by reverting only the source file and
    re-running: the recovery test fails, the hold-off test still passes, and the
    three pre-existing tests are unaffected either way.
  • No new warnings.

New coverage:

  • a central that keeps resubscribing recovers once the backoff elapses — the
    regression itself, and it asserts re-admission happens at or after the
    capped backoff, not merely that it happens.
  • a hammering central is still held off for the whole backoff — the
    counterpart, so the fix cannot be "corrected" into admitting early and
    weakening the enumeration defence.

The three existing tests (first-allowed/then-limited, suppression threshold,
stale pruning) are untouched and still pass.

BLESubscriptionAnnounceLimiter refreshed lastAnnounceTime on rejected
attempts as well as allowed ones. That field is the reference for both
the backoff comparison and pruneStaleEntries, so a central that
resubscribed faster than the capped 30s backoff kept resetting its own
clock: elapsed time never grew past the backoff, attemptCount climbed
past bleSubscriptionRateLimitMaxAttempts, and suppressAnnounce stayed
true for as long as it kept trying.

The BCH-01-004 limiter exists to blunt enumeration attacks, but a
legitimate central with a flapping BLE link resubscribes the same way --
and once suppressed it could never be re-admitted, so we stayed
invisible to that peer indefinitely.

Keep lastAnnounceTime at the last allowed announce, which is what
TransportConfig documents it as ("minimum interval between announces per
central"). Escalation is unchanged: attemptCount still climbs and
suppressAnnounce still engages, but a central is re-admitted once the
capped backoff has elapsed since it was last actually announced to.
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.

1 participant