Fix critical security issues in fragment reassembly and file cleanup

Fragment Reassembly Race Condition (CRITICAL):
- Wrap all incomingFragments/fragmentMetadata access in collectionsQueue.sync
- Prevents concurrent modification crashes from multi-threaded access
- Minimizes lock contention by doing heavy work (reassembly/decode) outside locks
- Add upper bound check: reject fragments with total > 10,000 (DoS prevention)
- Add cumulative size validation before storing fragments (memory DoS prevention)

File Cleanup Path Traversal (CRITICAL):
- Use NSString.lastPathComponent to extract filename safely
- Prevents directory traversal attacks via malicious filenames
- Add path prefix validation before file deletion
- Now checks both incoming and outgoing directories (fixes disk leak)

Additional Protections:
- Fragment assemblies now limited by both count (128) and cumulative bytes (1MB)
- Explicit checks for "." and ".." filenames in cleanup
- Defense-in-depth: multiple validation layers
This commit is contained in:
jack
2025-10-14 22:30:10 +02:00
parent d76472999d
commit c1430eaeb9
2 changed files with 91 additions and 49 deletions
+64 -35
View File
@@ -1255,7 +1255,7 @@ final class BLEService: NSObject {
if peerID == myPeerID { if peerID == myPeerID {
return return
} }
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
guard packet.payload.count >= 13 else { return } guard packet.payload.count >= 13 else { return }
@@ -1274,47 +1274,76 @@ final class BLEService: NSObject {
let originalType = packet.payload[12] let originalType = packet.payload[12]
let fragmentData = packet.payload.suffix(from: 13) let fragmentData = packet.payload.suffix(from: 13)
// Sanity checks // Sanity checks - add reasonable upper bound on total to prevent DoS
guard total > 0 && index >= 0 && index < total else { return } guard total > 0 && total <= 10000 && index >= 0 && index < total else { return }
// Store fragment // Compute fragment key for this assembly
let key = FragmentKey(sender: senderU64, id: fragU64) let key = FragmentKey(sender: senderU64, id: fragU64)
if incomingFragments[key] == nil {
// Cap in-flight assemblies to prevent memory/battery blowups
if incomingFragments.count >= maxInFlightAssemblies {
// Evict the oldest assembly by timestamp
if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
incomingFragments.removeValue(forKey: oldest)
fragmentMetadata.removeValue(forKey: oldest)
}
}
incomingFragments[key] = [:]
fragmentMetadata[key] = (originalType, total, Date())
SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session)
}
incomingFragments[key]?[index] = Data(fragmentData)
SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session)
// Check if complete // Critical section: Store fragment and check completion status
if let fragments = incomingFragments[key], var shouldReassemble: Bool = false
fragments.count == total { var fragmentsToReassemble: [Int: Data]? = nil
// Reassemble
var reassembled = Data() collectionsQueue.sync(flags: .barrier) {
for i in 0..<total { if incomingFragments[key] == nil {
if let fragment = fragments[i] { // Cap in-flight assemblies to prevent memory/battery blowups
reassembled.append(fragment) if incomingFragments.count >= maxInFlightAssemblies {
// Evict the oldest assembly by timestamp
if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
incomingFragments.removeValue(forKey: oldest)
fragmentMetadata.removeValue(forKey: oldest)
}
} }
incomingFragments[key] = [:]
fragmentMetadata[key] = (originalType, total, Date())
SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session)
} }
// Decode the original packet bytes we reassembled, so flags/compression are preserved // Check cumulative size before storing this fragment
if let originalPacket = BinaryProtocol.decode(reassembled) { let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session) guard currentSize + fragmentData.count <= FileTransferLimits.maxPayloadBytes else {
handleReceivedPacket(originalPacket, from: peerID) // Exceeds size limit - evict this assembly
SecureLogger.warning("🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes), evicting", category: .security)
incomingFragments.removeValue(forKey: key)
fragmentMetadata.removeValue(forKey: key)
shouldReassemble = false
fragmentsToReassemble = nil
return
}
incomingFragments[key]?[index] = Data(fragmentData)
SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session)
// Check if complete
if let fragments = incomingFragments[key], fragments.count == total {
shouldReassemble = true
fragmentsToReassemble = fragments
} else { } else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session) shouldReassemble = false
fragmentsToReassemble = nil
} }
}
// Cleanup
// Heavy work outside lock: reassemble and decode
guard shouldReassemble, let fragments = fragmentsToReassemble else { return }
var reassembled = Data()
for i in 0..<total {
if let fragment = fragments[i] {
reassembled.append(fragment)
}
}
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if let originalPacket = BinaryProtocol.decode(reassembled) {
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
handleReceivedPacket(originalPacket, from: peerID)
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
}
// Critical section: Cleanup completed assembly
collectionsQueue.sync(flags: .barrier) {
incomingFragments.removeValue(forKey: key) incomingFragments.removeValue(forKey: key)
fragmentMetadata.removeValue(forKey: key) fragmentMetadata.removeValue(forKey: key)
} }
+27 -14
View File
@@ -2712,21 +2712,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
private func cleanupLocalFile(forMessage message: BitchatMessage) { private func cleanupLocalFile(forMessage message: BitchatMessage) {
let prefixes = ["[voice] ": "voicenotes/outgoing", // Check both outgoing and incoming directories for thorough cleanup
"[image] ": "images/outgoing", let prefixes = ["[voice] ", "[image] ", "[file] "]
"[file] ": "files/outgoing"] let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
guard let entry = prefixes.first(where: { message.content.hasPrefix($0.key) }) else { return } "images/outgoing", "images/incoming",
let filename = String(message.content.dropFirst(entry.key.count)).trimmingCharacters(in: .whitespacesAndNewlines) "files/outgoing", "files/incoming"]
guard !filename.isEmpty, let base = try? applicationFilesDirectory() else { return }
let target = base.appendingPathComponent(entry.value, isDirectory: true).appendingPathComponent(filename)
// Security: Remove file directly (no TOCTOU race) guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
do { let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
try FileManager.default.removeItem(at: target) guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
} catch CocoaError.fileNoSuchFile {
// Expected // Security: Extract only the last path component to prevent directory traversal
} catch { let safeFilename = (rawFilename as NSString).lastPathComponent
SecureLogger.error("Failed to cleanup \(filename): \(error)", category: .session) guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
// Try all possible locations (outgoing and incoming)
for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
// Security: Verify target is within expected directory before deletion
guard target.path.hasPrefix(base.path) else { continue }
do {
try FileManager.default.removeItem(at: target)
} catch CocoaError.fileNoSuchFile {
// Expected - file not in this directory
} catch {
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
}
} }
} }