diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 4b15bb58..7fd3da91 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -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() diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 7428fc55..c5a252c5 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -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 { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 6153c266..9efb9776 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -129,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 diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index d282a6f5..7ae30796 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -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 diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 4ea8020f..bee9c1c3 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -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