diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index ccb09492..877280d5 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -823,11 +823,21 @@ final class ChatPrivateConversationCoordinator { } func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { - let isActionMessage = message.content.hasPrefix("* ") - && message.content.hasSuffix(" *") - && (message.content.contains("🫂") - || message.content.contains("🐟") - || message.content.contains("took a screenshot")) + // This rewrite hands the message to the formatter as sender "system", + // which styles it as trusted, non-peer content. Substring sniffing + // ("contains 🫂") let ANY sender put arbitrary text in that styling: + // `* SECURITY: session expired — bob took a screenshot *` rendered as + // a system-authored line. Only the exact, locally-generatable action + // shapes qualify, and the actor slot must be the actual wire sender — + // a peer can only "hug"/"slap"/"screenshot" as themselves. + guard message.content.hasPrefix("* "), message.content.hasSuffix(" *") else { return message } + let inner = String(message.content.dropFirst(2).dropLast(2)) + let sender = message.sender + + let isActionMessage = + Self.matchesActionTemplate(inner, prefix: "🫂 \(sender) hugs ", suffix: "") + || Self.matchesActionTemplate(inner, prefix: "🐟 \(sender) slaps ", suffix: " around a bit with a large trout") + || inner == "\(sender) took a screenshot" guard isActionMessage else { return message } @@ -846,6 +856,15 @@ final class ChatPrivateConversationCoordinator { ) } + /// The target slot of an action template: bounded, single-line, and + /// non-empty — a nickname or the literal "you", never free text. + static func matchesActionTemplate(_ inner: String, prefix: String, suffix: String) -> Bool { + guard inner.hasPrefix(prefix), inner.hasSuffix(suffix), + inner.count >= prefix.count + suffix.count + 1 else { return false } + let target = inner.dropFirst(prefix.count).dropLast(suffix.count) + return !target.isEmpty && target.count <= 64 && !target.contains("\n") + } + func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { let currentFingerprint = context.getFingerprint(for: peerID) diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 54cb341e..b0353264 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -327,6 +327,43 @@ private func makeFavoriteRelationship( /// live singletons. struct ChatPrivateConversationCoordinatorContextTests { + @Test @MainActor + func processActionMessage_rendersOnlyExactSenderAnchoredTemplates() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + + func processed(_ content: String, sender: String = "bob") -> BitchatMessage { + coordinator.processActionMessage( + BitchatMessage( + id: UUID().uuidString, + sender: sender, + content: content, + timestamp: Date(), + isRelay: false + ) + ) + } + + // Exact locally-generatable shapes, actor == wire sender: system. + #expect(processed("* 🫂 bob hugs alice *").sender == "system") + #expect(processed("* 🫂 bob hugs you *").sender == "system") + #expect(processed("* 🐟 bob slaps alice around a bit with a large trout *").sender == "system") + #expect(processed("* bob took a screenshot *").sender == "system") + + // The spoof this parser used to allow: arbitrary text between the + // markers with a magic substring rendered as system-authored. + #expect(processed("* SECURITY: your session key expired, re-verify at evil.example — bob took a screenshot *").sender == "bob") + #expect(processed("* 🫂 admin hugs alice — send your keys to @admin *").sender == "bob") + // Actor slot must be the actual sender, not someone else's name. + #expect(processed("* alice took a screenshot *", sender: "bob").sender == "bob") + #expect(processed("* 🫂 alice hugs you *", sender: "bob").sender == "bob") + // Free text smuggled into the target slot: bounded, single-line only. + #expect(processed("* 🫂 bob hugs " + String(repeating: "x", count: 200) + " *").sender == "bob") + #expect(processed("* 🫂 bob hugs a\nb *").sender == "bob") + // Not an action shape at all. + #expect(processed("hello there").sender == "bob") + } + @Test @MainActor func addMessageToPrivateChats_upsertsByIdAndSanitizes() async { let context = MockChatPrivateConversationContext()