BCH-01-002: Switch from pending files to disk quota management

The original PR introduced a PendingFileManager that held incoming files
in memory until user acceptance. However, this approach had issues:
1. No UI was implemented for users to accept/decline files
2. Files would expire after 5 minutes, losing legitimate transfers
3. The app only allows specific media types (photos, audio) that users
   explicitly choose to send, so manual acceptance adds friction

This commit replaces the pending file system with disk quota management:
- Auto-save files immediately (preserving original UX)
- Enforce 100 MB storage quota for incoming files
- Auto-delete oldest files when quota is exceeded
- Logs cleanup activity for visibility

This directly addresses the DoS vulnerability (disk exhaustion from
file spam) while maintaining good UX for legitimate transfers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-01-12 15:17:09 -10:00
co-authored by Claude Opus 4.5
parent 49b1413d85
commit 4091a30f10
6 changed files with 116 additions and 751 deletions
-7
View File
@@ -179,9 +179,6 @@ protocol BitchatDelegate: AnyObject {
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
// Pending file transfer notification (file held in memory until user accepts)
func didReceivePendingFileTransfer(_ pending: PendingFileTransfer)
}
// Provide default implementation to make it effectively optional
@@ -201,8 +198,4 @@ extension BitchatDelegate {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation
}
func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) {
// Default empty implementation
}
}
+107 -45
View File
@@ -575,9 +575,6 @@ final class BLEService: NSObject {
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
meshTopology.reset()
// BCH-01-002: Clear pending files held in memory
PendingFileManager.shared.clearAll()
}
// MARK: Connectivity and peers
@@ -1158,30 +1155,63 @@ final class BLEService: NSObject {
return
}
// BCH-01-002: Enforce storage quota before saving
enforceIncomingFilesQuota(reservingBytes: filePacket.content.count)
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)
}
// 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
}
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
)
SecureLogger.debug("📁 Queued pending file from \(peerID.id.prefix(8))(\(filePacket.content.count) bytes, id: \(pending.id.prefix(8))…)", category: .session)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))-> \(destination.lastPathComponent)", category: .session)
notifyUI { [weak self] in
self?.delegate?.didReceivePendingFileTransfer(pending)
self?.delegate?.didReceiveMessage(message)
}
}
@@ -1363,38 +1393,70 @@ final class BLEService: NSObject {
}
}
// MARK: - Pending File Management (BCH-01-002)
// MARK: - Storage Quota 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 }
/// Maximum total storage for incoming files (100 MB)
private static let incomingFilesQuota: Int64 = 100 * 1024 * 1024
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"
/// Enforces storage quota for incoming files by deleting oldest files when quota is exceeded.
/// Call before saving a new incoming file.
private func enforceIncomingFilesQuota(reservingBytes: Int) {
do {
let base = try applicationFilesDirectory()
let incomingDirs = [
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
base.appendingPathComponent("images/incoming", isDirectory: true),
base.appendingPathComponent("files/incoming", isDirectory: true)
]
// Gather all incoming files with their sizes and modification dates
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
let fileManager = FileManager.default
for dir in incomingDirs {
guard fileManager.fileExists(atPath: dir.path) else { continue }
guard let contents = try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else { continue }
for fileURL in contents {
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let size = attrs.fileSize,
let modified = attrs.contentModificationDate else { continue }
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
}
}
return self.saveIncomingFile(
data: pending.content,
preferredName: pending.fileName,
subdirectory: subdirectory,
fallbackExtension: mime?.defaultExtension,
defaultPrefix: mime?.category.rawValue ?? "file"
)
}
}
// Calculate current usage
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
let targetUsage = Self.incomingFilesQuota - Int64(reservingBytes)
/// Declines a pending file without saving to disk.
func declinePendingFile(id: String) {
PendingFileManager.shared.declineFile(id: id)
guard currentUsage > targetUsage else { return }
// Sort by modification date (oldest first) and delete until under quota
let sortedFiles = allFiles.sorted { $0.modified < $1.modified }
var freedSpace: Int64 = 0
let needToFree = currentUsage - targetUsage
for file in sortedFiles {
guard freedSpace < needToFree else { break }
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
} catch {
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
}
}
if freedSpace > 0 {
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
}
} catch {
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
}
}
private func sendLeave() {
-262
View File
@@ -1,262 +0,0 @@
//
// 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.
/// Note: File is only removed from queue after successful save to prevent data loss.
func acceptFile(id: String, saveHandler: (PendingFileTransfer) -> URL?) -> URL? {
// First, get the pending file without removing it
let pending = queue.sync { () -> PendingFileTransfer? in
pendingFiles[id]
}
guard let pending = pending else {
SecureLogger.warning("Cannot accept file \(id.prefix(8))...: not found in pending queue", category: .session)
return nil
}
// Attempt to save the file
guard let url = saveHandler(pending) else {
// Save failed - keep the file in queue so user can retry
SecureLogger.error("Failed to save accepted file \(id.prefix(8))... - keeping in queue for retry", category: .session)
return nil
}
// Only remove from queue after successful save
_ = queue.sync(flags: .barrier) {
pendingFiles.removeValue(forKey: id)
}
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)
}
}
}
}
}
-44
View File
@@ -3218,50 +3218,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
}
// MARK: - Pending File Transfers (BCH-01-002)
func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) {
Task { @MainActor in
// Check if sender is blocked
if blockedUsers.contains(pending.senderNickname) {
SecureLogger.debug("Ignoring pending file from blocked user: \(pending.senderNickname)", category: .session)
PendingFileManager.shared.declineFile(id: pending.id)
return
}
// Create a system message to notify user about the pending file
let fileDescription = "\(pending.displayName) (\(ByteCountFormatter.string(fromByteCount: Int64(pending.fileSize), countStyle: .file)))"
let message = BitchatMessage(
id: "pending-\(pending.id)",
sender: pending.senderNickname,
content: "[Pending file: \(fileDescription)] - Accept or decline in file transfers",
timestamp: pending.timestamp,
isRelay: false,
originalSender: nil,
isPrivate: pending.isPrivate,
recipientNickname: pending.isPrivate ? nickname : nil,
senderPeerID: pending.senderPeerID,
mentions: nil
)
// Route to appropriate chat
if pending.isPrivate {
handlePrivateMessage(message)
} else {
handlePublicMessage(message)
}
// Send notification
NotificationService.shared.sendLocalNotification(
title: "Incoming file from \(pending.senderNickname)",
body: fileDescription,
identifier: "pending-file-\(pending.id)"
)
SecureLogger.debug("📁 Notified UI of pending file: \(pending.id.prefix(8))... from \(pending.senderNickname)", category: .session)
}
}
// MARK: - Peer Connection Events
func didConnectToPeer(_ peerID: PeerID) {