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(
+253
View File
@@ -0,0 +1,253 @@
//
// PendingFileManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
/// Represents a file transfer that has been received but not yet accepted by the user.
/// Files are held in memory until explicitly accepted, preventing DoS via storage exhaustion.
struct PendingFileTransfer: Identifiable {
let id: String
let senderPeerID: PeerID
let senderNickname: String
let fileName: String?
let mimeType: String?
let content: Data
let timestamp: Date
let isPrivate: Bool
var fileSize: Int { content.count }
var displayName: String {
if let name = fileName, !name.isEmpty {
return name
}
let ext = MimeType(mimeType)?.defaultExtension ?? "bin"
return "file.\(ext)"
}
var category: MimeType.Category {
MimeType(mimeType)?.category ?? .file
}
}
/// Manages pending file transfers with configurable limits to prevent memory exhaustion.
/// Files must be explicitly accepted before being written to disk.
final class PendingFileManager {
/// Shared instance for app-wide pending file management
static let shared = PendingFileManager()
/// Configuration for pending file limits
struct Config {
/// Maximum number of pending files allowed
let maxPendingCount: Int
/// Maximum total size of all pending files in bytes
let maxTotalBytes: Int
/// Maximum age before automatic expiration (seconds)
let expirationSeconds: TimeInterval
static let `default` = Config(
maxPendingCount: 10,
maxTotalBytes: 5 * 1024 * 1024, // 5 MiB total
expirationSeconds: 300 // 5 minutes
)
}
private let config: Config
private let queue = DispatchQueue(label: "chat.bitchat.pendingfiles", attributes: .concurrent)
private var pendingFiles: [String: PendingFileTransfer] = [:]
private var expirationTimer: Timer?
/// Callback when a pending file is added
var onPendingFileAdded: ((PendingFileTransfer) -> Void)?
/// Callback when a pending file expires or is removed
var onPendingFileRemoved: ((String) -> Void)?
init(config: Config = .default) {
self.config = config
startExpirationTimer()
}
deinit {
expirationTimer?.invalidate()
}
// MARK: - Public API
/// Adds a file to the pending queue. Returns the pending file ID if successful, nil if rejected.
/// Files may be rejected if limits are exceeded.
@discardableResult
func addPendingFile(
senderPeerID: PeerID,
senderNickname: String,
fileName: String?,
mimeType: String?,
content: Data,
isPrivate: Bool
) -> PendingFileTransfer? {
let id = UUID().uuidString
let pending = PendingFileTransfer(
id: id,
senderPeerID: senderPeerID,
senderNickname: senderNickname,
fileName: fileName,
mimeType: mimeType,
content: content,
timestamp: Date(),
isPrivate: isPrivate
)
return queue.sync(flags: .barrier) { [self] in
// Check count limit
if pendingFiles.count >= config.maxPendingCount {
SecureLogger.warning("Pending file rejected: max count (\(config.maxPendingCount)) reached", category: .security)
// Remove oldest file to make room
if let oldest = pendingFiles.values.min(by: { $0.timestamp < $1.timestamp }) {
pendingFiles.removeValue(forKey: oldest.id)
SecureLogger.debug("Evicted oldest pending file \(oldest.id.prefix(8))... to make room", category: .session)
}
}
// Check total size limit
let currentTotal = pendingFiles.values.reduce(0) { $0 + $1.fileSize }
if currentTotal + content.count > config.maxTotalBytes {
SecureLogger.warning("Pending file rejected: would exceed max total size (\(config.maxTotalBytes) bytes)", category: .security)
// Try to evict old files to make room
var evictedSize = 0
let sortedByAge = pendingFiles.values.sorted { $0.timestamp < $1.timestamp }
for old in sortedByAge {
if currentTotal - evictedSize + content.count <= config.maxTotalBytes {
break
}
pendingFiles.removeValue(forKey: old.id)
evictedSize += old.fileSize
SecureLogger.debug("Evicted pending file \(old.id.prefix(8))... (\(old.fileSize) bytes) for space", category: .session)
}
// Check again after eviction
let newTotal = pendingFiles.values.reduce(0) { $0 + $1.fileSize }
if newTotal + content.count > config.maxTotalBytes {
SecureLogger.warning("Cannot accept pending file even after eviction", category: .security)
return nil
}
}
pendingFiles[id] = pending
SecureLogger.debug("Added pending file \(id.prefix(8))... from \(senderPeerID.id.prefix(8))... (\(content.count) bytes)", category: .session)
DispatchQueue.main.async { [weak self] in
self?.onPendingFileAdded?(pending)
}
return pending
}
}
/// Retrieves a pending file by ID
func getPendingFile(id: String) -> PendingFileTransfer? {
queue.sync {
pendingFiles[id]
}
}
/// Retrieves all pending files
func getAllPendingFiles() -> [PendingFileTransfer] {
queue.sync {
Array(pendingFiles.values).sorted { $0.timestamp > $1.timestamp }
}
}
/// Accepts a pending file, saving it to disk and removing from pending queue.
/// Returns the saved file URL, or nil if the file was not found or save failed.
func acceptFile(id: String, saveHandler: (PendingFileTransfer) -> URL?) -> URL? {
let pending = queue.sync(flags: .barrier) { () -> PendingFileTransfer? in
pendingFiles.removeValue(forKey: id)
}
guard let pending = pending else {
SecureLogger.warning("Cannot accept file \(id.prefix(8))...: not found in pending queue", category: .session)
return nil
}
guard let url = saveHandler(pending) else {
SecureLogger.error("Failed to save accepted file \(id.prefix(8))...", category: .session)
return nil
}
SecureLogger.debug("Accepted and saved pending file \(id.prefix(8))... to \(url.lastPathComponent)", category: .session)
return url
}
/// Declines/removes a pending file without saving
func declineFile(id: String) {
queue.sync(flags: .barrier) {
if let removed = pendingFiles.removeValue(forKey: id) {
SecureLogger.debug("Declined pending file \(id.prefix(8))... from \(removed.senderNickname)", category: .session)
}
}
DispatchQueue.main.async { [weak self] in
self?.onPendingFileRemoved?(id)
}
}
/// Clears all pending files (e.g., for panic mode)
func clearAll() {
let ids = queue.sync(flags: .barrier) { () -> [String] in
let ids = Array(pendingFiles.keys)
pendingFiles.removeAll()
return ids
}
SecureLogger.debug("Cleared \(ids.count) pending files", category: .session)
for id in ids {
DispatchQueue.main.async { [weak self] in
self?.onPendingFileRemoved?(id)
}
}
}
/// Returns current statistics
var stats: (count: Int, totalBytes: Int) {
queue.sync {
(pendingFiles.count, pendingFiles.values.reduce(0) { $0 + $1.fileSize })
}
}
// MARK: - Private
private func startExpirationTimer() {
DispatchQueue.main.async { [weak self] in
self?.expirationTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.expireOldFiles()
}
}
}
private func expireOldFiles() {
let now = Date()
let expiredIDs = queue.sync(flags: .barrier) { () -> [String] in
var expired: [String] = []
for (id, pending) in pendingFiles {
if now.timeIntervalSince(pending.timestamp) > config.expirationSeconds {
expired.append(id)
pendingFiles.removeValue(forKey: id)
}
}
return expired
}
if !expiredIDs.isEmpty {
SecureLogger.debug("Expired \(expiredIDs.count) pending files", category: .session)
for id in expiredIDs {
DispatchQueue.main.async { [weak self] in
self?.onPendingFileRemoved?(id)
}
}
}
}
}
+7
View File
@@ -58,6 +58,10 @@ protocol Transport: AnyObject {
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
}
extension Transport {
@@ -70,6 +74,9 @@ extension Transport {
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
}
protocol TransportPeerEventsDelegate: AnyObject {