mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:45:20 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user