Files
bitchat/bitchatTests/MessageRateLimiterTests.swift
T
593fd7d737 Harden public intake bounds against untrusted growth (#1451)
* Bound public rate-limit buckets against attacker-keyed growth.

Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop Cashu-looking text from skipping long-message guards.

Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap teleported geohash participant markers.

Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound untrusted Nostr relay frames and event tags.

Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fail soft when Noise handshake state is unexpectedly missing.

Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap geohash nickname cache from remote Nostr events.

FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Avoid overlapping exclusive access in the rate limiter.

Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix rate-limiter tests for mutating allow under #expect.

Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop unused WebSocket data helper; reset rate limiter on panic wipe.

dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:25:46 +02:00

203 lines
6.8 KiB
Swift

//
// MessageRateLimiterTests.swift
// bitchatTests
//
// Tests for the public-intake token buckets, including the NIP-13
// proof-of-work relaxation of the per-sender bucket.
//
import Foundation
import Testing
@testable import bitchat
struct MessageRateLimiterTests {
private func makeLimiter(
senderCapacity: Double = 2,
contentCapacity: Double = 100
) -> MessageRateLimiter {
MessageRateLimiter(
senderCapacity: senderCapacity,
senderRefillPerSec: 0.0001,
contentCapacity: contentCapacity,
contentRefillPerSec: 0.0001
)
}
@Test func senderBucketBlocksAfterCapacity() {
var limiter = makeLimiter()
let now = Date()
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now)
#expect(first)
#expect(second)
#expect(!third)
#expect(otherSender)
}
@Test func validPoWBypassesExhaustedSenderBucket() {
var limiter = makeLimiter()
let now = Date()
// Exhaust the sender bucket with plain (no-PoW) messages.
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
// A message carrying sufficient validated PoW still passes, and so
// does more-than-sufficient PoW; plain messages stay blocked.
let powExact = limiter.allow(
senderKey: "s",
contentKey: "c4",
powBits: NostrPoW.rateLimitBypassBits,
now: now
)
let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now)
let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now)
#expect(first)
#expect(second)
#expect(!exhausted)
#expect(powExact)
#expect(powHigh)
#expect(!plainAgain)
}
@Test func lowPoWDoesNotBypassSenderBucket() {
var limiter = makeLimiter(senderCapacity: 1)
let now = Date()
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
let lowPow = limiter.allow(
senderKey: "s",
contentKey: "c2",
powBits: NostrPoW.rateLimitBypassBits - 1,
now: now
)
let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now)
#expect(first)
#expect(!lowPow)
#expect(!zeroPow)
}
@Test func powDoesNotBypassContentFloodBucket() {
var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1)
let now = Date()
let first = limiter.allow(senderKey: "a", contentKey: "same", now: now)
// Identical content spammed with PoW is still throttled by the
// content bucket: PoW only relaxes the per-sender limit.
let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now)
let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now)
#expect(first)
#expect(!powSameContent)
#expect(powNewContent)
}
@Test func powBypassDoesNotDrainSenderBucket() {
var limiter = makeLimiter(senderCapacity: 1)
let now = Date()
// PoW messages don't consume sender tokens, so a subsequent plain
// message still has its full budget.
let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now)
let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now)
let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now)
#expect(powFirst)
#expect(powSecond)
#expect(plain)
#expect(!plainExhausted)
}
@Test("Content buckets do not grow when sender is rate limited")
func contentBucketsDoNotGrowAfterSenderLimit() {
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: 10,
maxContentBuckets: 10,
bucketIdleTTL: 60
)
let now = Date()
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
var rejected = true
for index in 1...100 {
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
rejected = false
}
}
#expect(first)
#expect(rejected)
#expect(limiter.bucketCountsForTesting.sender == 1)
#expect(limiter.bucketCountsForTesting.content == 1)
}
@Test("Bucket maps evict entries at configured caps")
func bucketMapsEvictAtConfiguredCaps() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
for index in 0..<25 {
_ = limiter.allow(
senderKey: "sender-\(index)",
contentKey: "content-\(index)",
now: now.addingTimeInterval(TimeInterval(index))
)
}
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
@Test("PoW bypass still creates content buckets under the cap")
func powBypassCreatesBoundedContentBuckets() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 100,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
var allAllowed = true
for index in 0..<10 {
let allowed = limiter.allow(
senderKey: "sender",
contentKey: "content-\(index)",
powBits: NostrPoW.rateLimitBypassBits,
now: now.addingTimeInterval(TimeInterval(index))
)
if !allowed { allAllowed = false }
}
#expect(allAllowed)
#expect(limiter.bucketCountsForTesting.sender == 0)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
}