mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
fix(security): prevent notifications from blocked users [BCH-01-012]
Block notifications from bypassing the user blocking feature on iOS. Vulnerabilities fixed: 1. Nostr public messages: checkForMentions() was called regardless of blocking status, allowing blocked users to trigger mention notifications 2. Noise-encrypted DMs: didReceiveNoisePayload() didn't check blocking before calling handlePrivateMessage(), allowing DM notifications Changes: - ChatViewModel+Nostr.swift: Add blocking check before checkForMentions() and sendHapticFeedback() in subscribeNostrEvent - ChatViewModel.swift: Add isPeerBlocked() check in didReceiveNoisePayload before processing private messages - Add NotificationBlockingTests.swift with 5 tests for blocking behavior Security: Blocked users can no longer trigger notifications through any message path (Nostr public, Nostr DM, or BLE Noise DM). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3052,20 +3052,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
switch type {
|
switch type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
||||||
|
|
||||||
|
// BCH-01-012: Check blocking before processing private message to prevent notification bypass
|
||||||
|
if isPeerBlocked(peerID) {
|
||||||
|
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
||||||
let pmMentions = parseMentions(from: pm.content)
|
let pmMentions = parseMentions(from: pm.content)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: pm.messageID,
|
id: pm.messageID,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
content: pm.content,
|
content: pm.content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: nickname,
|
recipientNickname: nickname,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
mentions: pmMentions.isEmpty ? nil : pmMentions
|
mentions: pmMentions.isEmpty ? nil : pmMentions
|
||||||
)
|
)
|
||||||
handlePrivateMessage(msg)
|
handlePrivateMessage(msg)
|
||||||
// Send delivery ACK back over BLE
|
// Send delivery ACK back over BLE
|
||||||
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
||||||
|
|||||||
@@ -130,12 +130,21 @@ extension ChatViewModel {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
// BCH-01-012: Check blocking before any notifications
|
||||||
|
// handlePublicMessage has its own blocking check but returns silently,
|
||||||
|
// so we must also guard checkForMentions to prevent notification bypass
|
||||||
|
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||||
|
|
||||||
handlePublicMessage(msg)
|
handlePublicMessage(msg)
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
// Only check mentions and send haptic if sender is not blocked
|
||||||
|
if !isBlocked {
|
||||||
|
checkForMentions(msg)
|
||||||
|
sendHapticFeedback(for: msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// NotificationBlockingTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
// BCH-01-012: Tests for notification blocking feature
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct NotificationBlockingTests {
|
||||||
|
|
||||||
|
// MARK: - Nostr Blocking Tests
|
||||||
|
|
||||||
|
@Test("isNostrBlocked returns true for blocked pubkeys")
|
||||||
|
func isNostrBlocked_returnsTrueForBlockedPubkey() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = MockIdentityManager(keychain)
|
||||||
|
|
||||||
|
let testPubkey = "abc123def456".lowercased()
|
||||||
|
|
||||||
|
// Initially not blocked
|
||||||
|
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)
|
||||||
|
|
||||||
|
// Block the pubkey
|
||||||
|
manager.setNostrBlocked(testPubkey, isBlocked: true)
|
||||||
|
|
||||||
|
// Now should be blocked
|
||||||
|
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == true)
|
||||||
|
|
||||||
|
// Unblock
|
||||||
|
manager.setNostrBlocked(testPubkey, isBlocked: false)
|
||||||
|
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isBlocked returns true for blocked fingerprints")
|
||||||
|
func isBlocked_returnsTrueForBlockedFingerprint() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = MockIdentityManager(keychain)
|
||||||
|
|
||||||
|
let testFingerprint = "fingerprint123"
|
||||||
|
|
||||||
|
// Initially not blocked
|
||||||
|
#expect(manager.isBlocked(fingerprint: testFingerprint) == false)
|
||||||
|
|
||||||
|
// Block the fingerprint
|
||||||
|
manager.setBlocked(testFingerprint, isBlocked: true)
|
||||||
|
|
||||||
|
// Now should be blocked
|
||||||
|
#expect(manager.isBlocked(fingerprint: testFingerprint) == true)
|
||||||
|
|
||||||
|
// Unblock
|
||||||
|
manager.setBlocked(testFingerprint, isBlocked: false)
|
||||||
|
#expect(manager.isBlocked(fingerprint: testFingerprint) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("getBlockedNostrPubkeys returns all blocked pubkeys")
|
||||||
|
func getBlockedNostrPubkeys_returnsAllBlocked() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = MockIdentityManager(keychain)
|
||||||
|
|
||||||
|
let pubkey1 = "pubkey1".lowercased()
|
||||||
|
let pubkey2 = "pubkey2".lowercased()
|
||||||
|
let pubkey3 = "pubkey3".lowercased()
|
||||||
|
|
||||||
|
manager.setNostrBlocked(pubkey1, isBlocked: true)
|
||||||
|
manager.setNostrBlocked(pubkey2, isBlocked: true)
|
||||||
|
manager.setNostrBlocked(pubkey3, isBlocked: true)
|
||||||
|
|
||||||
|
let blocked = manager.getBlockedNostrPubkeys()
|
||||||
|
|
||||||
|
#expect(blocked.count == 3)
|
||||||
|
#expect(blocked.contains(pubkey1))
|
||||||
|
#expect(blocked.contains(pubkey2))
|
||||||
|
#expect(blocked.contains(pubkey3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Blocking Tests
|
||||||
|
|
||||||
|
@Test("BitchatMessage with blocked sender is identified")
|
||||||
|
func bitchatMessage_blockedSenderIdentified() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = MockIdentityManager(keychain)
|
||||||
|
|
||||||
|
let blockedFingerprint = "blocked_fingerprint_123"
|
||||||
|
manager.setBlocked(blockedFingerprint, isBlocked: true)
|
||||||
|
|
||||||
|
#expect(manager.isBlocked(fingerprint: blockedFingerprint) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Case insensitive blocking for Nostr pubkeys")
|
||||||
|
func nostrBlocking_caseInsensitive() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = MockIdentityManager(keychain)
|
||||||
|
|
||||||
|
let pubkeyLower = "abc123def456"
|
||||||
|
|
||||||
|
// Block lowercase
|
||||||
|
manager.setNostrBlocked(pubkeyLower, isBlocked: true)
|
||||||
|
|
||||||
|
// Check lowercase is blocked
|
||||||
|
#expect(manager.isNostrBlocked(pubkeyHexLowercased: pubkeyLower) == true)
|
||||||
|
|
||||||
|
// Note: The API expects lowercased input, so callers must normalize
|
||||||
|
// This test verifies the contract that pubkeys should be lowercased before checking
|
||||||
|
// The fix in ChatViewModel+Nostr.swift normalizes via event.pubkey.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user