diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index 9d9eb41ac..07c2b4f81 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -317,6 +317,7 @@ A34D222A27294C67004E0220 /* PubLookupTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34D222827294C67004E0220 /* PubLookupTest.swift */; }; A36108E9273C7A2E00A90E34 /* MockError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36108E8273C7A2E00A90E34 /* MockError.swift */; }; A3B7C31923F576BA0022D628 /* AppStartup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B7C31823F576BA0022D628 /* AppStartup.swift */; }; + AEC30B9A2DAF433A8783F432 /* HTMLSanitizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDC7CECAFB84E949FB2DAC2 /* HTMLSanitizationTests.swift */; }; C132B9B41EC2DBD800763715 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C132B9B31EC2DBD800763715 /* AppDelegate.swift */; }; C132B9BB1EC2DBD800763715 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C132B9BA1EC2DBD800763715 /* Assets.xcassets */; }; C132B9BE1EC2DBD800763715 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C132B9BC1EC2DBD800763715 /* LaunchScreen.storyboard */; }; @@ -566,6 +567,7 @@ 32DCAC9C0512037018F434A1 /* BackendApi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackendApi.swift; sourceTree = ""; }; 32DCAEFF16F5D91A35791730 /* DataExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; 32DCAF8424D0185FAA9401A7 /* Imap+send.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Imap+send.swift"; sourceTree = ""; }; + 3BDC7CECAFB84E949FB2DAC2 /* HTMLSanitizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLSanitizationTests.swift; sourceTree = ""; }; 50531BE32629B9A80039BAE9 /* AttachmentNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentNode.swift; sourceTree = ""; }; 5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = ""; }; 510BB63427BE92CC00B1011F /* RecipientBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientBase.swift; sourceTree = ""; }; @@ -1666,6 +1668,7 @@ 9F7E902D26A1AD4C0021C07F /* Models */, D254733324C597CD00DEE698 /* CoreTypesTest.swift */, A3DAD5FD22E4574B00F2C4CD /* FlowCryptCoreTests.swift */, + 3BDC7CECAFB84E949FB2DAC2 /* HTMLSanitizationTests.swift */, 21594C9526F1DBA900BE654C /* data.txt */, ); path = Core; @@ -2601,6 +2604,7 @@ 9FC4117D268118AE004C0A69 /* PassPhraseStorageMock.swift in Sources */, 9F97650E267E16620058419D /* WKDURLsConstructorTests.swift in Sources */, D7478BDE2D09113100D42659 /* PasswordProtectedMsgTest.swift in Sources */, + AEC30B9A2DAF433A8783F432 /* HTMLSanitizationTests.swift in Sources */, 9F976585267E194F0058419D /* FlowCryptCoreTests.swift in Sources */, 9F6F3C3C26ADFBC7005BD9C6 /* CoreComposeMessageMock.swift in Sources */, 9FC4116B2681186D004C0A69 /* KeyMethodsTest.swift in Sources */, @@ -3917,7 +3921,7 @@ repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.13.5; + minimumVersion = 2.13.6; }; }; 95D83FB82A5D46C3006FDC33 /* XCRemoteSwiftPackageReference "SwiftLint" */ = { @@ -3925,7 +3929,7 @@ repositoryURL = "https://github.com/realm/SwiftLint"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.63.3; + minimumVersion = 0.65.0; }; }; 95F55F982A7B89260000E50F /* XCRemoteSwiftPackageReference "ProgressHUD" */ = { diff --git a/FlowCrypt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FlowCrypt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 253dc8394..52711e49b 100644 --- a/FlowCrypt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/FlowCrypt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "51c8c237beea1baa9cac64ef83cec68c6790506c", - "version" : "604.0.0-prerelease-2026-04-21" + "revision" : "a8b1c535647243d603b6772e7e8306c993a0c188", + "version" : "605.0.0-prerelease-2026-06-26" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint", "state" : { - "revision" : "70a5f3225d940d4573d3d2ffcf85b07ab2a6c5de", - "version" : "0.63.3" + "revision" : "fd768ba9a0e8a4f96d550d98de6c4cf2af565cf1", + "version" : "0.65.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/scinfu/SwiftSoup.git", "state" : { - "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", - "version" : "2.13.5" + "revision" : "ead56133a693d0184d8c2db1a6d6394410cacfd6", + "version" : "2.13.6" } }, { diff --git a/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift b/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift index 923ee0256..ee3a5c8da 100644 --- a/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift +++ b/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift @@ -44,7 +44,9 @@ final class ThreadDetailsViewController: TableNodeViewController { } } - var currentFolderPath: String { inboxItem.folderPath } + var currentFolderPath: String { + inboxItem.folderPath + } let onComposeMessageAction: ((ComposeMessageAction) -> Void)? let onComplete: MessageActionCompletion @@ -266,9 +268,11 @@ final class ThreadDetailsViewController: TableNodeViewController { isUsingKeyManager: appContext.clientConfigurationProvider.configuration.isUsingKeyManager ) + let sanitizedText = try await Core.shared.sanitizeHtml(html: decryptedText) + let processedMessage = ProcessedMessage( message: data.rawMessage, - text: decryptedText, + text: sanitizedText, type: .plain ) handle(processedMessage: processedMessage, at: indexPath) diff --git a/FlowCrypt/Core/Core.swift b/FlowCrypt/Core/Core.swift index f3d9f3ee8..3dcdb6b6f 100644 --- a/FlowCrypt/Core/Core.swift +++ b/FlowCrypt/Core/Core.swift @@ -262,11 +262,11 @@ class Core: KeyDecrypter, KeyParser, CoreComposeMessageType { return try r.json.decodeJson(as: CoreRes.ZxcvbnStrengthBar.self) } - private func waitUntilJavascirptReady(timeout: TimeInterval = 1) async throws { + func waitUntilJavaScriptReady(timeout: TimeInterval = 1) async throws { let functionName = "handleRequestFromHost" let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if await (try? webView.callAsyncJavaScript( + if let webView, await (try? webView.callAsyncJavaScript( "return typeof \(functionName) === 'function';", arguments: [:], contentWorld: .page @@ -285,7 +285,7 @@ class Core: KeyDecrypter, KeyParser, CoreComposeMessageType { let requestData = [UInt8](data) do { - try await waitUntilJavascirptReady() + try await waitUntilJavaScriptReady() let response = try await webView.callAsyncJavaScript( "return handleRequestFromHost(\"\(endpoint)\", \(paramsData), \(requestData))", arguments: [:], diff --git a/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageHelper.swift b/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageHelper.swift index e6092cc54..b54a48531 100644 --- a/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageHelper.swift +++ b/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageHelper.swift @@ -130,7 +130,9 @@ final class MessageHelper { } private func fetchOrDownloadData(for attachment: MessageAttachment, messageId: Identifier) async throws -> Data { - if let data = attachment.data { return data } + if let data = attachment.data { + return data + } return try await download(attachment: attachment, messageId: messageId, progressHandler: nil) } @@ -232,6 +234,31 @@ final class MessageHelper { private func process( message: Message, with decrypted: CoreRes.ParseDecryptMsg + ) async throws -> ProcessedMessage { + var attachments: [MessageAttachment] = if message.raw != nil || message.attachments.isEmpty { + decrypted.blocks.compactMap(\.attMeta).compactMap(MessageAttachment.init) + } else { + message.attachments + } + + let keyDetails = try await getKeyDetailsFromAttachment( + attachments: &attachments, + messageId: message.identifier + ) + + return try await Self.makeProcessedMessage( + message: message, + decrypted: decrypted, + attachments: attachments, + keyDetails: keyDetails + ) + } + + static func makeProcessedMessage( + message: Message, + decrypted: CoreRes.ParseDecryptMsg, + attachments: [MessageAttachment], + keyDetails: [KeyDetails] ) async throws -> ProcessedMessage { let firstBlockParseErr = decrypted.blocks.first { $0.type == .blockParseErr } let firstDecryptErrBlock = decrypted.blocks.first { $0.type == .decryptErr } @@ -260,25 +287,17 @@ final class MessageHelper { signature = nil } else { // decrypt / process success - text = decrypted.text + text = try await Core.shared.sanitizeHtml(html: decrypted.text) messageType = decrypted.replyType == ReplyType.encrypted ? .encrypted : .plain - signature = await evaluateSignatureVerificationResult( + signature = evaluateSignatureVerificationResult( signature: decrypted.blocks.first?.verifyRes ) } - var attachments: [MessageAttachment] = if message.raw != nil || message.attachments.isEmpty { - decrypted.blocks.compactMap(\.attMeta).compactMap(MessageAttachment.init) - } else { - message.attachments - } - - let keyDetails: [KeyDetails] = try await getKeyDetailsFromAttachment(attachments: &attachments, messageId: message.identifier) - // Also extract keyDetails from publicKey blocks (for encrypted messages) let publicKeyBlockDetails: [KeyDetails] = decrypted.blocks .filter { $0.type == .publicKey } - .compactMap { $0.keyDetails } + .compactMap(\.keyDetails) return ProcessedMessage( message: message, @@ -343,7 +362,9 @@ extension MessageHelper { guard let sender else { return [] } let pubKeys = try localContactsProvider.retrievePubKeys(for: sender.email, shouldUpdateLastUsed: false).map(\.armored) - if pubKeys.isNotEmpty || onlyLocal { return pubKeys } + if pubKeys.isNotEmpty || onlyLocal { + return pubKeys + } // try? because we may ignore update remote result try? await pubLookup.fetchRemoteUpdateLocal(with: sender) @@ -353,9 +374,9 @@ extension MessageHelper { return contact.pubKeys.map(\.armored) } - private func evaluateSignatureVerificationResult( + private static func evaluateSignatureVerificationResult( signature: MsgBlock.VerifyRes? - ) async -> ProcessedMessage.MessageSignature { + ) -> ProcessedMessage.MessageSignature { guard let signature else { return .unsigned } if let error = signature.error { diff --git a/FlowCrypt/Functionality/Mail Provider/Message Provider/ProcessedMessage.swift b/FlowCrypt/Functionality/Mail Provider/Message Provider/ProcessedMessage.swift index eff0679fd..b1bb15269 100644 --- a/FlowCrypt/Functionality/Mail Provider/Message Provider/ProcessedMessage.swift +++ b/FlowCrypt/Functionality/Mail Provider/Message Provider/ProcessedMessage.swift @@ -115,8 +115,7 @@ extension ProcessedMessage { let (text, quote) = Self.parseHtmlQuote(from: html) self.text = try await Core.shared.sanitizeHtml(html: text) if let quote { - // SanitizeHtml replaces > with > so need to convert it back - self.quote = try await (Core.shared.sanitizeHtml(html: quote)).replacingOccurrences(of: ">", with: ">") + self.quote = try await Core.shared.sanitizeHtml(html: quote) } else { self.quote = nil } @@ -201,7 +200,7 @@ extension ProcessedMessage { guard let lastLine = lines.popLast() else { break } let trimmedLine = lastLine.trimmingCharacters(in: .whitespaces) - if trimmedLine.isEmpty || trimmedLine.hasPrefix(">") { + if trimmedLine.isEmpty || trimmedLine.hasPrefix(">") || trimmedLine.hasPrefix(">") { quoteLines.insert(lastLine, at: 0) } else { if trimmedLine.hasPrefix("On "), trimmedLine.hasSuffix(" wrote:") { @@ -223,11 +222,17 @@ extension ProcessedMessage { } var attributedMessage: NSAttributedString { - String(text.prefix(maxLength)).attributed(color: type.textColor) + Self.decodeHTMLEntities(in: String(text.prefix(maxLength))) + .attributed(color: type.textColor) } var attributedQuote: NSAttributedString? { guard let quote else { return nil } - return String(quote.prefix(maxLength)).attributed(color: type.textColor.withAlphaComponent(0.8)) + return Self.decodeHTMLEntities(in: String(quote.prefix(maxLength))) + .attributed(color: type.textColor.withAlphaComponent(0.8)) + } + + private static func decodeHTMLEntities(in text: String) -> String { + (try? Entities.unescape(text)) ?? text } } diff --git a/FlowCryptAppTests/Core/HTMLSanitizationTests.swift b/FlowCryptAppTests/Core/HTMLSanitizationTests.swift new file mode 100644 index 000000000..46286fb1d --- /dev/null +++ b/FlowCryptAppTests/Core/HTMLSanitizationTests.swift @@ -0,0 +1,292 @@ +// +// HTMLSanitizationTests.swift +// +// Created by Mart on 30/06/2026 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +@testable import FlowCrypt +import XCTest + +class HTMLSanitizationTests: XCTestCase { + let core: Core = .shared + + override func setUp() async throws { + try await super.setUp() + try await core.waitUntilJavaScriptReady(timeout: 10) + } + + // MARK: - End-to-end: decrypt a PGP-encrypted XSS payload, then verify sanitization + + func testDecryptedXssPayloadIsStrippedAfterFullRoundtrip() async throws { + let key = TestData.k0 + let attackHtml = """ + + +

FC_DECRYPTED_PLAINTEXT_JS_POC

+ + + + """ + + let encrypted = try await core.encrypt( + data: attackHtml.data(), + pubKeys: [key.public], + password: nil + ) + + let decrypted = try await core.parseDecryptMsg( + encrypted: encrypted, + keys: [key], + msgPwd: nil, + isMime: false, + verificationPubKeys: [] + ) + + let sanitized = try await core.sanitizeHtml(html: decrypted.text) + + XCTAssertTrue( + sanitized.contains("FC_DECRYPTED_PLAINTEXT_JS_POC"), + "benign content must survive roundtrip" + ) + + XCTAssertFalse( + sanitized.contains("FC_ENTITY_ENCODED_XSS_POC + <script> + fetch('https://example.com/entity-xss-callback') + </script> + """ + let encodedPayload = """ + MIME-Version: 1.0 + Content-Type: text/html; charset=UTF-8 + + \(encodedHtml) + """ + let encrypted = try await core.encrypt( + data: encodedPayload.data(), + pubKeys: [key.public], + password: nil + ) + let decrypted = try await core.parseDecryptMsg( + encrypted: encrypted, + keys: [key], + msgPwd: nil, + isMime: false, + verificationPubKeys: [] + ) + + XCTAssertTrue( + decrypted.text.contains(" + """ + let sanitized = try await core.sanitizeHtml(html: input) + XCTAssertNotNil(sanitized.range(of: "Hello")) + XCTAssertNil(sanitized.range(of: "Click me

" + let sanitized = try await core.sanitizeHtml(html: input) + XCTAssertNotNil(sanitized.range(of: "Click me")) + XCTAssertNil(sanitized.range(of: "onclick")) + XCTAssertNil(sanitized.range(of: "alert")) + } + + func testSanitizeHtmlStripsJavascriptProtocolInLinks() async throws { + let input = "click" + let sanitized = try await core.sanitizeHtml(html: input) + XCTAssertNil(sanitized.range(of: "javascript:")) + } + + func testSanitizeHtmlPreservesSafePlainText() async throws { + let input = "This is a plain text message with no HTML." + let sanitized = try await core.sanitizeHtml(html: input) + XCTAssertEqual(sanitized, input) + } + + func testSanitizeHtmlPreservesSafeFormatting() async throws { + let input = "bold and italic" + let sanitized = try await core.sanitizeHtml(html: input) + XCTAssertNotNil(sanitized.range(of: "bold")) + XCTAssertNotNil(sanitized.range(of: "italic")) + } + + func testProcessedMessageDoesNotDecodeEscapedForbiddenMarkup() async throws { + let input = "
Safe content
<style>body { display: none; }</style>" + let message = Message( + identifier: .random, + date: .now, + sender: nil, + subject: nil, + size: nil, + labels: [], + attachmentIds: [], + body: MessageBody(text: "", html: input, attachment: nil) + ) + + let processedMessage = try await ProcessedMessage(message: message) + + XCTAssertTrue(processedMessage.text.contains("
Safe content
")) + XCTAssertTrue(processedMessage.text.contains("<style>")) + XCTAssertFalse(processedMessage.text.contains(" - + \(html ?? "") """ // swiftlint:enable line_length diff --git a/FlowCryptUI/Nodes/WebNode.swift b/FlowCryptUI/Nodes/WebNode.swift index 4b94aec4a..42da890d5 100644 --- a/FlowCryptUI/Nodes/WebNode.swift +++ b/FlowCryptUI/Nodes/WebNode.swift @@ -13,14 +13,20 @@ class CustomWebViewNode: ASDisplayNode { private let webViewNode: ASDisplayNode override init() { - // Create a display node for the WKWebView webViewNode = ASDisplayNode { () -> UIView in + let makeWebView = { + let preferences = WKWebpagePreferences() + preferences.allowsContentJavaScript = false + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences = preferences + return WKWebView(frame: .zero, configuration: config) + } if Thread.isMainThread { - return WKWebView() + return makeWebView() } else { var webView: WKWebView? DispatchQueue.main.sync { - webView = WKWebView() + webView = makeWebView() } return webView ?? UIView() }