From 593fd7d737227c7fad598721f41858304f174d7f Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 25 Jul 2026 15:55:46 +0530 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Cursor --- bitchat/App/LocationPresenceStore.swift | 121 ++++++++++++++++-- bitchat/Noise/NoiseSession.swift | 5 +- bitchat/Nostr/NostrProtocol.swift | 19 +++ bitchat/Nostr/NostrRelayManager.swift | 18 ++- .../Services/MessageFormattingEngine.swift | 6 +- bitchat/Services/TransportConfig.swift | 12 ++ bitchat/ViewModels/ChatMessageFormatter.swift | 6 +- bitchat/ViewModels/ChatViewModel.swift | 1 + .../ChatViewModelBootstrapper.swift | 11 ++ bitchat/ViewModels/MessageRateLimiter.swift | 87 +++++++++++-- bitchat/ViewModels/NostrInboundPipeline.swift | 5 +- .../Views/Components/TextMessageView.swift | 4 +- bitchat/Views/MessageTextHelpers.swift | 20 +++ bitchatTests/AppArchitectureTests.swift | 38 ++++++ bitchatTests/ChatViewModelTests.swift | 19 +++ .../MessageFormattingEngineTests.swift | 26 ++++ bitchatTests/MessageRateLimiterTests.swift | 83 ++++++++++++ bitchatTests/NostrProtocolTests.swift | 58 +++++++++ 18 files changed, 504 insertions(+), 35 deletions(-) diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift index 499eeb86..b4f6aaef 100644 --- a/bitchat/App/LocationPresenceStore.swift +++ b/bitchat/App/LocationPresenceStore.swift @@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject { @Published private(set) var geoNicknames: [String: String] = [:] @Published private(set) var teleportedGeo: Set = [] + 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 = [] + 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) { + 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) { - teleportedGeo = Set(pubkeys.map { $0.lowercased() }) + guard teleportedGeoCapacity > 0 else { + clearTeleportedGeo() + return + } + + var seen: Set = [] + 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) { + 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() } } diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index 1df2f0ba..3ffa1ffc 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -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 { diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 72144e9e..46aa7c07 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -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() diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 9f295a69..9864ab21 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -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 } } } diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 4bd4b073..43adcb19 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -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) } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index f8e4f8c3..a7503229 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -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 diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index cd9ee480..62723d7a 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -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 diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 24caaa86..4ab0eea8 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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() diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 1f67f3d5..99ca2886 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -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() { diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift index 1196c47f..4551e2a2 100644 --- a/bitchat/ViewModels/MessageRateLimiter.swift +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -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) + } } } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index bc5ae744..4b1a2110 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -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) { diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 2e1009d7..48ccb427 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -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) { diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index c684d3f1..fd438f10 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -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. diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 69b7fc89..2fdb94df 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -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")) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 5682cdfa..aa4fc5a8 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -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() diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 602ad38d..5de2adea 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -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 diff --git a/bitchatTests/MessageRateLimiterTests.swift b/bitchatTests/MessageRateLimiterTests.swift index c9959761..d9025beb 100644 --- a/bitchatTests/MessageRateLimiterTests.swift +++ b/bitchatTests/MessageRateLimiterTests.swift @@ -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) + } } diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 1f9d21ab..da24b4ec 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -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