fix BCH-01-002: prevent DoS via file storage exhaustion

Incoming files are now held in memory via PendingFileManager instead of
being auto-saved to disk. Users must explicitly accept files before they
are written. This prevents attackers from exhausting device storage.

Key changes:
- Add PendingFileManager with configurable limits (max 10 files, 5MB total)
- Files auto-expire after 5 minutes if not accepted
- LRU eviction when limits are exceeded
- Pending files cleared during panic mode (emergencyDisconnectAll)
- Add didReceivePendingFileTransfer delegate method
- Add acceptPendingFile/declinePendingFile to Transport protocol

Security audit reference: Cure53 BCH-01-002 (Medium severity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-01-12 10:39:19 -10:00
co-authored by Claude Opus 4.5
parent b84c36c6fa
commit 4b3077169a
6 changed files with 683 additions and 57 deletions
+55 -48
View File
@@ -550,7 +550,7 @@ final class BLEService: NSObject {
func emergencyDisconnectAll() {
stopServices()
// Clear all sessions and peers
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
let entries = activeTransfers.map { ($0.key, $0.value.workItems) }
@@ -565,16 +565,19 @@ final class BLEService: NSObject {
entry.items.forEach { $0.cancel() }
TransferProgressManager.shared.cancel(id: entry.id)
}
// Clear processed messages
messageDeduplicator.reset()
// Clear peripheral references
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
meshTopology.reset()
// BCH-01-002: Clear pending files held in memory
PendingFileManager.shared.clearAll()
}
// MARK: Connectivity and peers
@@ -1155,60 +1158,30 @@ final class BLEService: NSObject {
return
}
let fallbackExt = mime.defaultExtension
let subdirectory: String
switch mime.category {
case .audio:
subdirectory = "voicenotes/incoming"
case .image:
subdirectory = "images/incoming"
case .file:
subdirectory = "files/incoming"
}
guard let destination = saveIncomingFile(
data: filePacket.content,
preferredName: filePacket.fileName,
subdirectory: subdirectory,
fallbackExtension: fallbackExt,
defaultPrefix: mime.category.rawValue
) else {
return
}
let marker: String
let fileName = destination.lastPathComponent
switch mime.category {
case .audio:
marker = "[voice] \(fileName)"
case .image:
marker = "[image] \(fileName)"
case .file:
marker = "[file] \(fileName)"
}
let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID
if isPrivateMessage {
updatePeerLastSeen(peerID)
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: marker,
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID
)
// BCH-01-002 Fix: Don't auto-save files to disk. Hold in memory as pending until user accepts.
// This prevents DoS via storage exhaustion from malicious peers flooding with files.
guard let pending = PendingFileManager.shared.addPendingFile(
senderPeerID: peerID,
senderNickname: senderNickname,
fileName: filePacket.fileName,
mimeType: filePacket.mimeType,
content: filePacket.content,
isPrivate: isPrivateMessage
) else {
SecureLogger.warning("🚫 Rejected file transfer: pending file limits exceeded", category: .security)
return
}
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))-> \(destination.lastPathComponent)", category: .session)
SecureLogger.debug("📁 Queued pending file from \(peerID.id.prefix(8))(\(filePacket.content.count) bytes, id: \(pending.id.prefix(8))…)", category: .session)
notifyUI { [weak self] in
self?.delegate?.didReceiveMessage(message)
self?.delegate?.didReceivePendingFileTransfer(pending)
}
}
@@ -1390,6 +1363,40 @@ final class BLEService: NSObject {
}
}
// MARK: - Pending File Management (BCH-01-002)
/// Accepts a pending file and saves it to disk.
/// Returns the saved file URL, or nil if the file was not found or save failed.
func acceptPendingFile(id: String) -> URL? {
return PendingFileManager.shared.acceptFile(id: id) { [weak self] pending in
guard let self = self else { return nil }
let mime = MimeType(pending.mimeType)
let subdirectory: String
switch mime?.category ?? .file {
case .audio:
subdirectory = "voicenotes/incoming"
case .image:
subdirectory = "images/incoming"
case .file:
subdirectory = "files/incoming"
}
return self.saveIncomingFile(
data: pending.content,
preferredName: pending.fileName,
subdirectory: subdirectory,
fallbackExtension: mime?.defaultExtension,
defaultPrefix: mime?.category.rawValue ?? "file"
)
}
}
/// Declines a pending file without saving to disk.
func declinePendingFile(id: String) {
PendingFileManager.shared.declineFile(id: id)
}
private func sendLeave() {
SecureLogger.debug("👋 Sending leave announcement", category: .session)
let packet = BitchatPacket(