"; };
@@ -3921,7 +3921,7 @@
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = upToNextMajorVersion;
- minimumVersion = 2.13.5;
+ minimumVersion = 2.13.6;
};
};
95D83FB82A5D46C3006FDC33 /* XCRemoteSwiftPackageReference "SwiftLint" */ = {
@@ -3929,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 c646d3809..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,7 @@ final class ThreadDetailsViewController: TableNodeViewController {
isUsingKeyManager: appContext.clientConfigurationProvider.configuration.isUsingKeyManager
)
- let sanitizedText = try await (Core.shared.sanitizeHtml(html: decryptedText))
- .replacingOccurrences(of: ">", with: ">")
- .replacingOccurrences(of: "<", with: "<")
+ let sanitizedText = try await Core.shared.sanitizeHtml(html: decryptedText)
let processedMessage = ProcessedMessage(
message: data.rawMessage,
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 69fa99820..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,27 +287,17 @@ final class MessageHelper {
signature = nil
} else {
// decrypt / process success
- text = try await (Core.shared.sanitizeHtml(html: decrypted.text))
- .replacingOccurrences(of: ">", with: ">")
- .replacingOccurrences(of: "<", with: "<")
+ 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,
@@ -345,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)
@@ -355,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 9f6c1a65c..b1bb15269 100644
--- a/FlowCrypt/Functionality/Mail Provider/Message Provider/ProcessedMessage.swift
+++ b/FlowCrypt/Functionality/Mail Provider/Message Provider/ProcessedMessage.swift
@@ -113,14 +113,9 @@ extension ProcessedMessage {
self.type = .plain
if let html = message.body.html {
let (text, quote) = Self.parseHtmlQuote(from: html)
- self.text = try await (Core.shared.sanitizeHtml(html: text))
- .replacingOccurrences(of: ">", with: ">")
- .replacingOccurrences(of: "<", with: "<")
+ self.text = try await Core.shared.sanitizeHtml(html: text)
if let quote {
- // SanitizeHtml replaces > with > and < with < so need to convert them back
- self.quote = try await (Core.shared.sanitizeHtml(html: quote))
- .replacingOccurrences(of: ">", with: ">")
- .replacingOccurrences(of: "<", with: "<")
+ self.quote = try await Core.shared.sanitizeHtml(html: quote)
} else {
self.quote = nil
}
@@ -205,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:") {
@@ -227,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
index 00d90b855..46286fb1d 100644
--- a/FlowCryptAppTests/Core/HTMLSanitizationTests.swift
+++ b/FlowCryptAppTests/Core/HTMLSanitizationTests.swift
@@ -11,6 +11,11 @@ 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 {
@@ -61,6 +66,63 @@ class HTMLSanitizationTests: XCTestCase {
)
}
+ func testDecryptedEntityEncodedXssIsSanitizedByMessageHelper() async throws {
+ let key = TestData.k0
+ let encodedHtml = """
+ 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("