Compare commits

..
Author SHA1 Message Date
jack 41b7f5171e Fix Cashu long-message guard bypass 2026-06-02 22:19:09 +02:00
9 changed files with 71 additions and 132 deletions
@@ -255,9 +255,8 @@ final class MessageFormattingEngine {
isSelf: Bool, isSelf: Bool,
isMentioned: Bool isMentioned: Bool
) -> AttributedString { ) -> AttributedString {
// For very long content without special tokens, use plain formatting // For very long content, use plain formatting to avoid expensive regex and detector work.
let containsCashu = containsCashuToken(content) if content.isOversizedForRichFormatting() {
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf) return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
} }
-3
View File
@@ -56,9 +56,6 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0 static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3 static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5 static let uiContentRateBucketRefillPerSec: Double = 0.5
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// UI sleeps/delays // UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -70,12 +70,7 @@ final class ChatMessageFormatter {
let content = message.content let content = message.content
let nsContent = content as NSString let nsContent = content as NSString
let nsLen = nsContent.length let nsLen = nsContent.length
let containsCashuEarly: Bool = { if content.isOversizedForRichFormatting() {
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 {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf plainStyle.font = isSelf
+8 -62
View File
@@ -26,10 +26,6 @@ struct MessageRateLimiter {
} }
return false return false
} }
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
} }
private var senderBuckets: [String: TokenBucket] = [:] private var senderBuckets: [String: TokenBucket] = [:]
@@ -39,93 +35,43 @@ struct MessageRateLimiter {
private let senderRefill: Double private let senderRefill: Double
private let contentCapacity: Double private let contentCapacity: Double
private let contentRefill: Double private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init( init(
senderCapacity: Double, senderCapacity: Double,
senderRefillPerSec: Double, senderRefillPerSec: Double,
contentCapacity: Double, contentCapacity: Double,
contentRefillPerSec: Double, contentRefillPerSec: Double
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
) { ) {
self.senderCapacity = senderCapacity self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec self.contentRefill = contentRefillPerSec
self.maxSenderBuckets = max(1, maxSenderBuckets)
self.maxContentBuckets = max(1, maxContentBuckets)
self.bucketIdleTTL = bucketIdleTTL
} }
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = bucket( var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
for: senderKey,
in: &senderBuckets,
capacity: senderCapacity, capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill, refillPerSec: senderRefill,
maxBuckets: maxSenderBuckets, lastRefill: now
now: now
) )
let senderAllowed = senderBucket.allow(now: now) let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket senderBuckets[senderKey] = senderBucket
guard senderAllowed else { return false }
var contentBucket = bucket( var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
for: contentKey,
in: &contentBuckets,
capacity: contentCapacity, capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill, refillPerSec: contentRefill,
maxBuckets: maxContentBuckets, lastRefill: now
now: now
) )
let contentAllowed = contentBucket.allow(now: now) let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket contentBuckets[contentKey] = contentBucket
return contentAllowed return senderAllowed && contentAllowed
} }
mutating func reset() { mutating func reset() {
senderBuckets.removeAll() senderBuckets.removeAll()
contentBuckets.removeAll() contentBuckets.removeAll()
} }
var bucketCountsForTesting: (sender: Int, content: Int) {
(senderBuckets.count, contentBuckets.count)
}
private mutating func bucket(
for key: String,
in buckets: inout [String: TokenBucket],
capacity: Double,
refillPerSec: Double,
maxBuckets: Int,
now: Date
) -> TokenBucket {
if let bucket = buckets[key] {
return bucket
}
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, now: now)
return TokenBucket(
capacity: capacity,
tokens: capacity,
refillPerSec: refillPerSec,
lastRefill: now
)
}
private func evictIfNeeded(from buckets: inout [String: TokenBucket], maxBuckets: Int, now: Date) {
guard buckets.count >= maxBuckets else { return }
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: bucketIdleTTL) }
guard buckets.count >= maxBuckets else { return }
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
buckets.removeValue(forKey: oldestKey)
}
}
} }
@@ -22,7 +22,7 @@ struct TextMessageView: View {
let cashuLinks = message.content.extractCashuLinks() let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, 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) let isExpanded = expandedMessageIDs.contains(message.id)
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme)) Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -38,7 +38,7 @@ struct TextMessageView: View {
} }
// Expand/Collapse for very long messages // 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 isExpanded = expandedMessageIDs.contains(message.id)
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) { Button(labelKey) {
+13
View File
@@ -21,6 +21,19 @@ extension String {
return current >= threshold return current >= threshold
} }
// Detect if message content should be collapsed to avoid expensive unbounded layout.
func isLongForDisplay(
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
// Detect if message content should use plain formatting to avoid expensive regex parsing.
func isOversizedForRichFormatting(lengthThreshold: Int = 4000, tokenThreshold: Int = 1024) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuLinks(max: Int = 3) -> [String] { func extractCashuLinks(max: Int = 3) -> [String] {
let regex = MessageFormattingEngine.Patterns.cashu let regex = MessageFormattingEngine.Patterns.cashu
+19
View File
@@ -606,6 +606,25 @@ struct ChatViewModelFormattingTests {
#expect(String(header.characters) == "<@Alice#a1b2> ") #expect(String(header.characters) == "<@Alice#a1b2> ")
} }
@Test @MainActor
func formatMessageAsText_longCashuMessageUsesPlainFastPath() 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)]")
}
} }
// MARK: - Verification Tests // MARK: - Verification Tests
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
// Exactly at threshold DOES trigger (uses >= comparison) // Exactly at threshold DOES trigger (uses >= comparison)
#expect(content.hasVeryLongToken(threshold: 50)) #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 @MainActor
@@ -1,56 +0,0 @@
//
// MessageRateLimiterTests.swift
// bitchatTests
//
// Ensures public-message rate limiter state remains bounded for attacker-derived keys.
//
import Foundation
import Testing
@testable import bitchat
struct MessageRateLimiterTests {
@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()
#expect(limiter.allow(senderKey: "sender", contentKey: "content-0", now: now))
for index in 1...100 {
#expect(!limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now))
}
#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 {
#expect(limiter.allow(senderKey: "sender-\(index)", contentKey: "content-\(index)", now: now.addingTimeInterval(TimeInterval(index))))
}
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
}