Compare commits

..
Author SHA1 Message Date
jack 7a1aac584e Harden Nostr tag handling 2026-06-02 22:19:02 +02:00
7 changed files with 91 additions and 123 deletions
+15
View File
@@ -501,6 +501,10 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent
}
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
self.created_at = createdAt
@@ -509,6 +513,17 @@ struct NostrEvent: Codable {
self.content = content
self.sig = dict["sig"] as? String
}
private 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()
+1
View File
@@ -963,6 +963,7 @@ private enum ParsedInbound {
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
data.count <= TransportConfig.nostrMaxInboundMessageBytes,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
+6 -3
View File
@@ -56,9 +56,6 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
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
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -132,6 +129,12 @@ enum TransportConfig {
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// Nostr inbound event safety limits
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024
// Nostr helpers
static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16
@@ -262,8 +262,10 @@ final class ChatNostrCoordinator {
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return }
viewModel.deduplicationService.recordNostrEvent(event.id)
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
SecureLogger.debug(
"GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tagCount=\(event.tags.count)",
category: .session
)
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
+8 -62
View File
@@ -26,10 +26,6 @@ struct MessageRateLimiter {
}
return false
}
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
}
private var senderBuckets: [String: TokenBucket] = [:]
@@ -39,93 +35,43 @@ 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,
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
contentRefillPerSec: Double
) {
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
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = bucket(
for: senderKey,
in: &senderBuckets,
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
maxBuckets: maxSenderBuckets,
now: now
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
guard senderAllowed else { return false }
var contentBucket = bucket(
for: contentKey,
in: &contentBuckets,
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
maxBuckets: maxContentBuckets,
now: now
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return contentAllowed
return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.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)
}
}
}
@@ -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)
}
}
+57
View File
@@ -241,6 +241,51 @@ struct NostrProtocolTests {
#expect(!signed.isValidSignature())
}
@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)
}
@Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws {
let since = Date(timeIntervalSince1970: 1_234_567)
let filter = NostrFilter.geohashNotes("u4pruyd", since: since, limit: 42)
@@ -254,6 +299,18 @@ struct NostrProtocolTests {
}
// 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