From 50347325157d1d2d68cd70ced33217875db654ec Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:33:37 +0200 Subject: [PATCH] Simplify validation, compression heuristics, and notification scheduling (#841) * Simplify validation, compression heuristics, and notification scheduling * Consolidate notification logic and add InputValidator monitoring Follow-up improvements to address PR feedback: 1. Consolidate notification functions - Add interruptionLevel parameter to sendLocalNotification - Refactor sendNetworkAvailableNotification to use consolidated function - Removes 12 lines of duplicate code 2. Add monitoring to InputValidator - Log control character rejections for production monitoring - Privacy-preserving: logs length + count, not actual content - Uses .security category for proper log routing 3. Add comprehensive InputValidator tests - 28 test cases covering validation, control characters, unicode, edge cases - Ensures behavioral changes are well-tested and documented --------- Co-authored-by: jack --- bitchat/Services/NotificationService.swift | 71 ++++---- bitchat/Utils/CompressionUtil.swift | 16 +- bitchat/Utils/InputValidator.swift | 34 ++-- bitchatTests/InputValidatorTests.swift | 192 +++++++++++++++++++++ 4 files changed, 246 insertions(+), 67 deletions(-) create mode 100644 bitchatTests/InputValidatorTests.swift diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 31d28c1c..f654fd37 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -29,28 +29,30 @@ final class NotificationService { } } - func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) { - // For now, skip app state check entirely to avoid thread issues - // The NotificationDelegate will handle foreground presentation - DispatchQueue.main.async { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - if let userInfo = userInfo { - content.userInfo = userInfo - } - - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: nil // Deliver immediately - ) - - UNUserNotificationCenter.current().add(request) { _ in - // Notification added - } + func sendLocalNotification( + title: String, + body: String, + identifier: String, + userInfo: [String: Any]? = nil, + interruptionLevel: UNNotificationInterruptionLevel = .active + ) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.interruptionLevel = interruptionLevel + + if let userInfo = userInfo { + content.userInfo = userInfo } + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil // Deliver immediately + ) + + UNUserNotificationCenter.current().add(request) } func sendMentionNotification(from sender: String, message: String) { @@ -83,25 +85,12 @@ final class NotificationService { let title = "πŸ‘₯ bitchatters nearby!" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let identifier = "network-available-\(Date().timeIntervalSince1970)" - - // For network notifications, we want to show them even in foreground - // No app state check - let the notification delegate handle presentation - DispatchQueue.main.async { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - content.interruptionLevel = .timeSensitive // Make it more prominent - - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: nil // Deliver immediately - ) - - UNUserNotificationCenter.current().add(request) { _ in - // Notification added - } - } + + sendLocalNotification( + title: title, + body: body, + identifier: identifier, + interruptionLevel: .timeSensitive + ) } } diff --git a/bitchat/Utils/CompressionUtil.swift b/bitchat/Utils/CompressionUtil.swift index 883fdaa8..0e238ced 100644 --- a/bitchat/Utils/CompressionUtil.swift +++ b/bitchat/Utils/CompressionUtil.swift @@ -61,15 +61,13 @@ struct CompressionUtil { // 1. Data is too small // 2. Data appears to be already compressed (high entropy) guard data.count >= compressionThreshold else { return false } - - // Simple entropy check - count unique bytes - var byteFrequency = [UInt8: Int]() - for byte in data { - byteFrequency[byte, default: 0] += 1 - } - - // If we have very high byte diversity, data is likely already compressed - let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256)) + + // Quick uniqueness check β€” a high diversity of bytes usually means the + // payload is already compressed. We only need to know how many unique + // values exist rather than keeping full frequency counts. + let uniqueByteCount = Set(data).count + let sampleSize = min(data.count, 256) + let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize) return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes } } diff --git a/bitchat/Utils/InputValidator.swift b/bitchat/Utils/InputValidator.swift index a6a981d9..468531f2 100644 --- a/bitchat/Utils/InputValidator.swift +++ b/bitchat/Utils/InputValidator.swift @@ -1,4 +1,5 @@ import Foundation +import BitLogger /// Comprehensive input validation for BitChat protocol /// Prevents injection attacks, buffer overflows, and malformed data @@ -16,29 +17,28 @@ struct InputValidator { // MARK: - String Content Validation /// Validates and sanitizes user-provided strings used in UI + /// + /// Rejects strings containing control characters to prevent potential security issues + /// and UI rendering problems. This strict approach ensures data integrity at input time. static func validateUserString(_ string: String, maxLength: Int) -> String? { - // Check empty - guard !string.isEmpty else { return nil } - - // Trim whitespace let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - - // Check length guard trimmed.count <= maxLength else { return nil } - // Remove control characters + // Reject control characters outright instead of rewriting the string. + // This prevents injection attacks and ensures consistent UI rendering. let controlChars = CharacterSet.controlCharacters - let cleaned = trimmed.components(separatedBy: controlChars).joined() - - // Ensure valid UTF-8 (should already be, but double-check) - guard cleaned.data(using: .utf8) != nil else { return nil } - - // Prevent zero-width characters and other invisible unicode - let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}") - let visible = cleaned.components(separatedBy: invisibleChars).joined() - - return visible.isEmpty ? nil : visible + if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) { + // Log rejection for monitoring, without exposing actual content for privacy + let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count + SecureLogger.debug( + "Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))", + category: .security + ) + return nil + } + + return trimmed } /// Validates nickname diff --git a/bitchatTests/InputValidatorTests.swift b/bitchatTests/InputValidatorTests.swift new file mode 100644 index 00000000..021d454a --- /dev/null +++ b/bitchatTests/InputValidatorTests.swift @@ -0,0 +1,192 @@ +// +// InputValidatorTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +struct InputValidatorTests { + + // MARK: - Basic Validation Tests + + @Test func validStringPassesValidation() throws { + let result = InputValidator.validateUserString("Hello World", maxLength: 100) + #expect(result == "Hello World") + } + + @Test func emptyStringReturnsNil() throws { + let result = InputValidator.validateUserString("", maxLength: 100) + #expect(result == nil) + } + + @Test func whitespaceOnlyStringReturnsNil() throws { + let result = InputValidator.validateUserString(" \n\t ", maxLength: 100) + #expect(result == nil) + } + + @Test func stringExceedingMaxLengthReturnsNil() throws { + let longString = String(repeating: "a", count: 101) + let result = InputValidator.validateUserString(longString, maxLength: 100) + #expect(result == nil) + } + + @Test func stringAtMaxLengthIsAccepted() throws { + let exactString = String(repeating: "a", count: 100) + let result = InputValidator.validateUserString(exactString, maxLength: 100) + #expect(result == exactString) + } + + @Test func whitespaceIsTrimmed() throws { + let result = InputValidator.validateUserString(" Hello ", maxLength: 100) + #expect(result == "Hello") + } + + // MARK: - Control Character Tests + + @Test func nullCharacterIsRejected() throws { + let stringWithNull = "Hello\u{0000}World" + let result = InputValidator.validateUserString(stringWithNull, maxLength: 100) + #expect(result == nil) + } + + @Test func bellCharacterIsRejected() throws { + let stringWithBell = "Hello\u{0007}World" + let result = InputValidator.validateUserString(stringWithBell, maxLength: 100) + #expect(result == nil) + } + + @Test func backspaceCharacterIsRejected() throws { + let stringWithBackspace = "Hello\u{0008}World" + let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100) + #expect(result == nil) + } + + @Test func escapeCharacterIsRejected() throws { + let stringWithEscape = "Hello\u{001B}World" + let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100) + #expect(result == nil) + } + + @Test func deleteCharacterIsRejected() throws { + let stringWithDelete = "Hello\u{007F}World" + let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100) + #expect(result == nil) + } + + @Test func multipleControlCharactersAreRejected() throws { + let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World" + let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100) + #expect(result == nil) + } + + // MARK: - Unicode and Special Character Tests + + @Test func emojiIsAccepted() throws { + let result = InputValidator.validateUserString("Hello πŸ‘‹ World", maxLength: 100) + #expect(result == "Hello πŸ‘‹ World") + } + + @Test func unicodeCharactersAreAccepted() throws { + let result = InputValidator.validateUserString("Hello δΈ–η•Œ Ω…Ψ±Ψ­Ψ¨Ψ§", maxLength: 100) + #expect(result == "Hello δΈ–η•Œ Ω…Ψ±Ψ­Ψ¨Ψ§") + } + + @Test func specialCharactersAreAccepted() throws { + let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100) + #expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?") + } + + // MARK: - Nickname Validation Tests + + @Test func validNicknameIsAccepted() throws { + let result = InputValidator.validateNickname("Alice") + #expect(result == "Alice") + } + + @Test func nicknameWithEmojiIsAccepted() throws { + let result = InputValidator.validateNickname("Alice πŸš€") + #expect(result == "Alice πŸš€") + } + + @Test func nicknameTooLongIsRejected() throws { + let longNickname = String(repeating: "a", count: 51) + let result = InputValidator.validateNickname(longNickname) + #expect(result == nil) + } + + @Test func nicknameAtMaxLengthIsAccepted() throws { + let exactNickname = String(repeating: "a", count: 50) + let result = InputValidator.validateNickname(exactNickname) + #expect(result == exactNickname) + } + + @Test func nicknameWithControlCharacterIsRejected() throws { + let result = InputValidator.validateNickname("Alice\u{0000}") + #expect(result == nil) + } + + // MARK: - Timestamp Validation Tests + + @Test func currentTimestampIsValid() throws { + let now = Date() + let result = InputValidator.validateTimestamp(now) + #expect(result == true) + } + + @Test func timestampWithinOneHourIsValid() throws { + let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60) + let result = InputValidator.validateTimestamp(thirtyMinutesAgo) + #expect(result == true) + } + + @Test func timestampTwoHoursAgoIsInvalid() throws { + let twoHoursAgo = Date().addingTimeInterval(-2 * 3600) + let result = InputValidator.validateTimestamp(twoHoursAgo) + #expect(result == false) + } + + @Test func timestampTwoHoursInFutureIsInvalid() throws { + let twoHoursFromNow = Date().addingTimeInterval(2 * 3600) + let result = InputValidator.validateTimestamp(twoHoursFromNow) + #expect(result == false) + } + + @Test func timestampAtOneHourBoundaryIsValid() throws { + // Just slightly within the one-hour window + let almostOneHourAgo = Date().addingTimeInterval(-3599) + let result = InputValidator.validateTimestamp(almostOneHourAgo) + #expect(result == true) + } + + // MARK: - Edge Cases + + @Test func singleCharacterStringIsAccepted() throws { + let result = InputValidator.validateUserString("a", maxLength: 100) + #expect(result == "a") + } + + @Test func stringWithOnlyNewlinesIsRejected() throws { + let result = InputValidator.validateUserString("\n\n\n", maxLength: 100) + #expect(result == nil) + } + + @Test func stringWithMixedWhitespaceIsTrimmed() throws { + let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100) + #expect(result == "Hello") + } + + @Test func stringWithLeadingControlCharacterIsRejected() throws { + let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100) + #expect(result == nil) + } + + @Test func stringWithTrailingControlCharacterIsRejected() throws { + let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100) + #expect(result == nil) + } +}