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>
This commit is contained in:
Taksh Kothari
2026-07-25 12:25:46 +02:00
committed by GitHub
co-authored by Cursor
parent 733098bb63
commit 593fd7d737
18 changed files with 504 additions and 35 deletions
+111 -10
View File
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
let normalized = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
}
func clearGeoNicknames() {
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
}
+4 -1
View File
@@ -66,7 +66,10 @@ class NoiseSession {
// Only initiator writes the first message
if role == .initiator {
let message = try handshakeState!.writeMessage()
guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
+19
View File
@@ -700,6 +700,10 @@ struct NostrEvent: Codable {
let content = dict["content"] as? String else {
throw NostrError.invalidEvent
}
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
@@ -709,6 +713,21 @@ struct NostrEvent: Codable {
self.content = content
self.sig = dict["sig"] as? String
}
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId()
+13 -5
View File
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
guard let data = message.dataWithinInboundLimit,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
}
private extension URLSessionWebSocketTask.Message {
var data: Data? {
/// Prefer rejecting oversized frames before UTF-8/Data materialization
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
switch self {
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
case .string(let text):
guard text.utf8.count <= maxBytes else { return nil }
return text.data(using: .utf8)
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
}
}
}
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
// For very long content, use plain formatting to avoid expensive
// regex/detector work. Cashu presence must not disable this guard.
if content.isOversizedForRichFormatting() {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
+12
View File
@@ -46,6 +46,7 @@ enum TransportConfig {
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let geoNicknameParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
@@ -81,6 +82,11 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Reject oversized/untrusted relay frames before JSON parse / store.
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
@@ -98,6 +104,12 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// Bound attacker-keyed bucket maps (sender IDs / content digests).
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// Cap teleported-participant markers so remote events cannot grow the set.
static let geoTeleportedParticipantsCap: Int = 1337
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
if content.isOversizedForRichFormatting() {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
+1
View File
@@ -1183,6 +1183,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
identityManager.clearAllIdentityData()
peerIdentityStore.clearAll()
locationPresenceStore.reset()
publicRateLimiter.reset()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
@@ -156,6 +156,17 @@ private extension ChatViewModelBootstrapper {
viewModel?.objectWillChange.send()
}
.store(in: &viewModel.cancellables)
viewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] people in
Task { @MainActor [weak viewModel] in
let visible = Set(people.map { $0.id })
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
}
}
.store(in: &viewModel.cancellables)
}
func loadPersistedViewState() {
+79 -8
View File
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
}
return false
}
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
}
private var senderBuckets: [String: TokenBucket] = [:]
@@ -35,17 +39,26 @@ struct MessageRateLimiter {
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
contentRefillPerSec: Double,
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
self.maxSenderBuckets = max(1, maxSenderBuckets)
self.maxContentBuckets = max(1, maxContentBuckets)
self.bucketIdleTTL = bucketIdleTTL
}
/// - Parameter powBits: validated NIP-13 difficulty of the event
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
if powBits >= NostrPoW.rateLimitBypassBits {
senderAllowed = true
} else {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
var senderBucket = Self.bucket(
for: senderKey,
in: &senderBuckets,
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
maxBuckets: maxSenderBuckets,
idleTTL: bucketIdleTTL,
now: now
)
senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
}
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
// Rejected senders must not mint attacker-keyed content entries.
guard senderAllowed else { return false }
var contentBucket = Self.bucket(
for: contentKey,
in: &contentBuckets,
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
maxBuckets: maxContentBuckets,
idleTTL: bucketIdleTTL,
now: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return senderAllowed && contentAllowed
return contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
var bucketCountsForTesting: (sender: Int, content: Int) {
(senderBuckets.count, contentBuckets.count)
}
// Static so we can take `inout` on a stored dictionary without overlapping
// exclusive access through a mutating method on `self`.
private static func bucket(
for key: String,
in buckets: inout [String: TokenBucket],
capacity: Double,
refillPerSec: Double,
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) -> TokenBucket {
if let existing = buckets[key] {
return existing
}
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
return TokenBucket(
capacity: capacity,
tokens: capacity,
refillPerSec: refillPerSec,
lastRefill: now
)
}
private static func evictIfNeeded(
from buckets: inout [String: TokenBucket],
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) {
guard buckets.count >= maxBuckets else { return }
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
guard buckets.count >= maxBuckets else { return }
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
buckets.removeValue(forKey: oldestKey)
}
}
}
@@ -196,7 +196,10 @@ final class NostrInboundPipeline {
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
SecureLogger.debug(
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
category: .session
)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
@@ -41,7 +41,7 @@ struct TextMessageView: View {
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isLong = message.content.isLongForDisplay()
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
@@ -103,7 +103,7 @@ struct TextMessageView: View {
}
// Expand/Collapse for very long messages
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
if message.content.isLongForDisplay() {
let isExpanded = expandedMessageIDs.contains(message.id)
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) {
+20
View File
@@ -21,6 +21,26 @@ extension String {
return current >= threshold
}
/// True when the message should collapse behind Show more in the UI.
/// Length alone decides this embedding a Cashu-looking token must not
/// disable the guard (remote DoS via unbounded layout).
func isLongForDisplay(
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
/// True when rich formatting (regex / link detectors) should be skipped.
/// Cashu presence used to exempt oversized content from the plain path;
/// that let untrusted input force expensive formatting work.
func isOversizedForRichFormatting(
lengthThreshold: Int = 4000,
tokenThreshold: Int = 1024
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
+38
View File
@@ -147,6 +147,44 @@ struct AppArchitectureTests {
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
@MainActor
func locationPresenceStoreBoundsTeleportedParticipants() {
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.markTeleported("AAAAAA")
store.markTeleported("BBBBBB")
store.markTeleported("CCCCCC")
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
#expect(store.teleportedGeo == Set(["cccccc"]))
store.setCurrentGeohash("u4pruz")
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
@MainActor
func locationPresenceStoreBoundsGeoNicknames() {
let store = LocationPresenceStore(geoNicknameCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.setNickname("alice", for: "AAAAAA")
store.setNickname("bob", for: "BBBBBB")
store.setNickname("carol", for: "CCCCCC")
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
#expect(store.geoNicknames == ["cccccc": "carol"])
store.setCurrentGeohash("u4pruz")
#expect(store.geoNicknames.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
+19
View File
@@ -646,6 +646,25 @@ struct ChatViewModelFormattingTests {
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageAsText_longCashuFallsBackToPlain() async {
let (viewModel, _) = makeTestableViewModel()
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "fmt-long-cashu",
sender: "Alice#a1b2",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
isRelay: false,
senderPeerID: PeerID(str: "00000000000000b3")
)
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageHeader_formatsSenderHeader() async {
let (viewModel, _) = makeTestableViewModel()
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
// Exactly at threshold DOES trigger (uses >= comparison)
#expect(content.hasVeryLongToken(threshold: 50))
}
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
let cashu = "cashuA" + String(repeating: "a", count: 40)
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
#expect(content.extractCashuLinks().count == 1)
#expect(content.isLongForDisplay())
}
@MainActor
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
let context = MockMessageFormattingContext(nickname: "carol")
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "long-cashu",
sender: "alice",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
}
}
@MainActor
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
#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)
}
}
+58
View File
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
@Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array(
repeating: ["g", "u4pruyd"],
count: TransportConfig.nostrMaxEventTags + 1
)
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [Array(
repeating: "value",
count: TransportConfig.nostrMaxEventTagValues + 1
)]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [[
"g",
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
]]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
let event = try NostrEvent(from: eventDict)
#expect(event.tags.count == 2)
}
// MARK: - Helpers
private static func validInboundEventDict() -> [String: Any] {
[
"id": String(repeating: "0", count: 64),
"pubkey": String(repeating: "1", count: 64),
"created_at": 1_234_567,
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
"tags": [["g", "u4pruyd"]],
"content": "hello",
"sig": String(repeating: "2", count: 128)
]
}
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4