diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a3850cd48..2cca59a41 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -746,6 +746,7 @@ 9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; }; 92720AB0DA9AB5EEF1DAF56B /* SecureBackupLogoutConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */; }; 9278EC51D24E57445B290521 /* AudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */; }; + 9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */; }; 92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */; }; 934051B17A884AB0635DF81B /* BlockedUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */; }; 937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85666E40F7E817809B4FD787 /* ComposerToolbar.swift */; }; @@ -1076,6 +1077,7 @@ D6DE764B17FB4A9A12C33BF4 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */; }; D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; D8459AAD6969B1431ECBE990 /* UnsupportedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E535B3388755B65C34CD10 /* UnsupportedRoomTimelineView.swift */; }; + D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */; }; D8CFA0EE46376F9FF04EEE45 /* TextRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4853C923A1AF43711D025EAF /* TextRoomTimelineView.swift */; }; D8F1462EA00AFC939FF9ACCA /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 203D1ACC20287F8986C959D3 /* target.yml */; }; D97C782FE0005995C36FA04A /* portrait_test_video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = E5E7D4EE7CA295E5039FDA21 /* portrait_test_video.mp4 */; }; @@ -1500,6 +1502,7 @@ 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DTHTMLElement+AttributedStringBuilder.swift"; sourceTree = ""; }; 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = ""; }; 1F7C6DDBB5D12F6EF6A3D6E1 /* CollapsibleReactionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleReactionLayout.swift; sourceTree = ""; }; + 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterSet.swift; sourceTree = ""; }; 1FAF8C2226A57B9AB7446B31 /* AppLockSetupPINScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenCoordinator.swift; sourceTree = ""; }; 1FD51B4D5173F7FC886F5360 /* NoticeRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItemContent.swift; sourceTree = ""; }; 1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMediaEventsTimelineView.swift; sourceTree = ""; }; @@ -3589,6 +3592,7 @@ 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */, 3339B1DDB1341E833D2555BC /* AVMetadataMachineReadableCodeObject.swift */, B6E89E530A8E92EC44301CA1 /* Bundle.swift */, + 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */, 9A1C33355FFB0F0953C35036 /* ClientBuilder.swift */, A9FAFE1C2149E6AC8156ED2B /* Collection.swift */, E2B1CC9AA154F4D5435BF60A /* Comparable.swift */, @@ -6595,6 +6599,7 @@ BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */, F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */, 9A3B0CDF097E3838FB1B9595 /* Bundle.swift in Sources */, + 9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */, 238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */, 0BAF83521871E69D222EE8E4 /* ClientBuilderHook.swift in Sources */, 211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */, @@ -6963,6 +6968,7 @@ BB6BF528BC7F5B87E08C4F18 /* CameraPicker.swift in Sources */, E14E469CD97550D0FC58F3CA /* CancellableTask.swift in Sources */, DF8F1211F2B0B56F0FCCA5C2 /* CertificateValidatorHook.swift in Sources */, + D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */, A52090A4FE0DB826578DFC03 /* Client.swift in Sources */, C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */, 87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */, diff --git a/ElementX/Sources/Application/Application.swift b/ElementX/Sources/Application/Application.swift index db844f8e6..1dec87543 100644 --- a/ElementX/Sources/Application/Application.swift +++ b/ElementX/Sources/Application/Application.swift @@ -11,6 +11,7 @@ import SwiftUI struct Application: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @Environment(\.openURL) private var openURL + @State private var alert: AlertInfo? private var appCoordinator: AppCoordinatorProtocol! @@ -34,13 +35,21 @@ struct Application: App { if appCoordinator.handleDeepLink(url, isExternalURL: false) { return .handled } + + if let confirmationParameters = url.confirmationParameters { + alert = .init(id: .confirmURL, + title: "Test", + message: "Test", + primaryButton: .init(title: "Confirm") { openURL(confirmationParameters.internalURL) }, + secondaryButton: .init(title: L10n.actionCancel, action: nil)) + + return .handled + } return .systemAction }) - .onOpenURL { - if !appCoordinator.handleDeepLink($0, isExternalURL: true) { - openURLInSystemBrowser($0) - } + .onOpenURL { url in + openURL(url) } .onContinueUserActivity("INStartVideoCallIntent") { userActivity in // `INStartVideoCallIntent` is to be replaced with `INStartCallIntent` @@ -50,10 +59,17 @@ struct Application: App { .task { appCoordinator.start() } + .alert(item: $alert) } } // MARK: - Private + + private func openURL(_ url: URL) { + if !appCoordinator.handleDeepLink(url, isExternalURL: true) { + openURLInSystemBrowser(url) + } + } /// Hide the status bar so it doesn't interfere with the screenshot tests private var shouldHideStatusBar: Bool { @@ -81,3 +97,7 @@ struct Application: App { openURL(url) } } + +enum ApplicationAlertType { + case confirmURL +} diff --git a/ElementX/Sources/Other/Extensions/CharacterSet.swift b/ElementX/Sources/Other/Extensions/CharacterSet.swift new file mode 100644 index 000000000..6eacb1dc7 --- /dev/null +++ b/ElementX/Sources/Other/Extensions/CharacterSet.swift @@ -0,0 +1,31 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +extension CharacterSet { + private static let urlAllowedSets: [CharacterSet] = [ + .urlUserAllowed, + .urlPasswordAllowed, + .urlHostAllowed, + .urlPathAllowed, + .urlQueryAllowed, + .urlFragmentAllowed + ] + + static let urlAllowedCharacters: CharacterSet = { + // Start by including hash, which isn't in any URL set + // Then include all URL-legal characters + var result = CharacterSet(charactersIn: "#") + for set in urlAllowedSets { + result.formUnion(set) + } + return result + }() + + static let matrixUserIDAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789._=-/@:") +} diff --git a/ElementX/Sources/Other/Extensions/URL.swift b/ElementX/Sources/Other/Extensions/URL.swift index a6d69bdc8..998c869fe 100644 --- a/ElementX/Sources/Other/Extensions/URL.swift +++ b/ElementX/Sources/Other/Extensions/URL.swift @@ -101,6 +101,20 @@ extension URL: @retroactive ExpressibleByStringLiteral { return nil } + static let confirmationScheme = "confirm" + + var requiresConfirmation: Bool { + scheme == Self.confirmationScheme + } + + var confirmationParameters: ConfirmURLParameters? { + guard requiresConfirmation, + let queryItems = URLComponents(url: self, resolvingAgainstBaseURL: true)?.queryItems else { + return nil + } + return ConfirmURLParameters(queryItems: queryItems) + } + // MARK: Mocks static var mockMXCAudio: URL { "mxc://matrix.org/1234567890AuDiO" } @@ -110,3 +124,25 @@ extension URL: @retroactive ExpressibleByStringLiteral { static var mockMXCAvatar: URL { "mxc://matrix.org/1234567890AvAtAr" } static var mockMXCUserAvatar: URL { "mxc://matrix.org/1234567890AvAtArUsEr" } } + +struct ConfirmURLParameters { + let internalURL: URL + let linkString: String + + var urlQueryItems: [URLQueryItem] { + [URLQueryItem(name: "internalURL", value: internalURL.absoluteString), + URLQueryItem(name: "linkString", value: linkString)] + } +} + +extension ConfirmURLParameters { + init?(queryItems: [URLQueryItem]) { + guard let internalURLString = queryItems.first(where: { $0.name == "internalURL" })?.value, + let internalURL = URL(string: internalURLString), + let externalURLString = queryItems.first(where: { $0.name == "linkString" })?.value else { + return nil + } + linkString = externalURLString + self.internalURL = internalURL + } +} diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index 4ccea1cd1..b2bf4cb04 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -98,6 +98,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) removeDefaultForegroundColors(mutableAttributedString) + detectPhishingAttempts(mutableAttributedString) addLinksAndMentions(mutableAttributedString) replaceMarkedBlockquotes(mutableAttributedString) replaceMarkedCodeBlocks(mutableAttributedString) @@ -177,19 +178,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { return nil } - var link = String(string[matchRange]) - - if !link.contains("://") { - link.insert(contentsOf: "https://", at: link.startIndex) - } - - // Don't include punctuation characters at the end of links - // e.g `https://element.io/blog:` <- which is a valid link but the wrong place - while !link.isEmpty, - link.rangeOfCharacter(from: .punctuationCharacters, options: .backwards)?.upperBound == link.endIndex { - link = String(link.dropLast()) - } - + let link = sanitizeLink(String(string[matchRange])) return TextParsingMatch(type: .link(urlString: link), range: match.range) }) @@ -278,7 +267,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { func detectPermalinks(_ attributedString: NSMutableAttributedString) { attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in if value != nil { - if let url = value as? URL, let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) { + if let url = value as? URL, + let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) { switch matrixEntity.id { case .user(let userID): mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil) @@ -303,6 +293,93 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { } } + private func sanitizeLink(_ string: String) -> String { + var link = string + if !link.contains("://") { + link.insert(contentsOf: "https://", at: link.startIndex) + } + + // Don't include punctuation characters at the end of links + // e.g `https://element.io/blog:` <- which is a valid link but the wrong place + while !link.isEmpty, + link.rangeOfCharacter(from: .punctuationCharacters, options: .backwards)?.upperBound == link.endIndex { + link = String(link.dropLast()) + } + + return link + } + + private func detectPhishingAttempts(_ attributedString: NSMutableAttributedString) { + attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + guard value != nil, let internalURL = value as? URL else { + return + } + let linkString = attributedString.attributedSubstring(from: range).string + // Some phishing attempts can be hidden by using the unicode character "﹒" instead of "." + let correctedLinkString = attributedString.attributedSubstring(from: range).string.replacingOccurrences(of: "﹒", with: ".") + + // We check if we the link string contains a matrix user ID. + if let match = MatrixEntityRegex.userIdentifierRegex.firstMatch(in: correctedLinkString), + let matchRange = Range(match.range, in: correctedLinkString) { + let identifier = String(correctedLinkString[matchRange]) + + // We also make sure that the link string is just the user ID + // We also trim any invalid character that might hide the phishing attempt + let trimmedLinkString = correctedLinkString.lowercased().trimmingCharacters(in: .matrixUserIDAllowedCharacters.inverted) + if identifier == trimmedLinkString, + isMatrixUserIDPhishingAttempt(internalURL: internalURL, identifier: identifier) { + handlePhishingAttempt(for: attributedString, in: range, internalURL: internalURL, linkString: linkString) + } + // Else we check if the link string is itself what is considered a tappable link for the OS + } else if MatrixEntityRegex.linkRegex.firstMatch(in: correctedLinkString) != nil { + // Then we compare the external URL with the internal one + // To avoid false positives like [Matrix.org](https://matrix.org) we sanitize and lowercase + let trimmedLinkString = sanitizeLink(correctedLinkString).lowercased().trimmingCharacters(in: .urlAllowedCharacters.inverted) + if sanitizeLink(correctedLinkString).lowercased() != sanitizeLink(internalURL.absoluteString).lowercased() { + handlePhishingAttempt(for: attributedString, in: range, internalURL: internalURL, linkString: linkString) + } + // Else we check if we the link string contains a matrix user ID. + } + } + } + + private func isMatrixUserIDPhishingAttempt(internalURL: URL, identifier: String) -> Bool { + // if is not a matrix entity then is a phishing attempt + guard let internalMatrixEntity = parseMatrixEntityFrom(uri: internalURL.absoluteString) else { + return true + } + + // If it is we check if is a user + switch internalMatrixEntity.id { + case .user(let id): + // If it is, and it does not match the external one, it's a phishing attempt + return id != identifier + default: + break + } + return true + } + + private func handlePhishingAttempt(for attributedString: NSMutableAttributedString, + in range: NSRange, + internalURL: URL, + linkString: String) { + // Let's remove the existing link attribute + attributedString.removeAttribute(.link, range: range) + + var urlComponents = URLComponents() + urlComponents.scheme = URL.confirmationScheme + urlComponents.host = "" + let parameters = ConfirmURLParameters(internalURL: internalURL, linkString: linkString) + urlComponents.queryItems = parameters.urlQueryItems + + guard let finalURL = urlComponents.url else { + return + } + + attributedString.addAttribute(.link, value: finalURL, range: range) + } + private func removeDTCoreTextArtifacts(_ attributedString: NSMutableAttributedString) { guard attributedString.length > 0 else { return diff --git a/ElementX/Sources/Other/Pills/MessageText.swift b/ElementX/Sources/Other/Pills/MessageText.swift index 24297de61..d91fceb52 100644 --- a/ElementX/Sources/Other/Pills/MessageText.swift +++ b/ElementX/Sources/Other/Pills/MessageText.swift @@ -137,11 +137,14 @@ struct MessageText: UIViewRepresentable { return true } } - + @available(iOS 17.0, *) func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? { switch textItem.content { case let .link(url): + guard !url.requiresConfirmation else { + return nil + } // We don't want to show a URL preview for permalinks let isPermalink = parseMatrixEntityFrom(uri: url.absoluteString) != nil return .init(preview: isPermalink ? nil : .default, menu: defaultMenu) diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 684c33e33..da35d2d6a 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -102,6 +102,7 @@ targets: - path: ../../ElementX/Sources/Other/Extensions/UNNotificationContent.swift - path: ../../ElementX/Sources/Other/Extensions/URL.swift - path: ../../ElementX/Sources/Other/Extensions/UTType.swift + - path: ../../ElementX/Sources/Other/Extensions/CharacterSet.swift - path: ../../ElementX/Sources/Other/HTMLParsing - path: ../../ElementX/Sources/Other/InfoPlistReader.swift - path: ../../ElementX/Sources/Other/Logging diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 903c0cb22..adc3e47cd 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -136,9 +136,9 @@ class AttributedStringBuilderTests: XCTestCase { } func testRenderHTMLStringWithLinkInHeader() { - let h1HTMLString = "

Matrix.org

" - let h2HTMLString = "

Matrix.org

" - let h3HTMLString = "

Matrix.org

" + let h1HTMLString = "

Matrix.org

" + let h2HTMLString = "

Matrix.org

" + let h3HTMLString = "

Matrix.org

" guard let h1AttributedString = attributedStringBuilder.fromHTML(h1HTMLString), let h2AttributedString = attributedStringBuilder.fromHTML(h2HTMLString), @@ -168,9 +168,9 @@ class AttributedStringBuilderTests: XCTestCase { XCTAssert(h1Font.pointSize > UIFont.preferredFont(forTextStyle: .body).pointSize) XCTAssert(h1Font.pointSize <= maxHeaderPointSize) - XCTAssertEqual(h1AttributedString.runs.first?.link?.host, "www.matrix.org") - XCTAssertEqual(h2AttributedString.runs.first?.link?.host, "www.matrix.org") - XCTAssertEqual(h3AttributedString.runs.first?.link?.host, "www.matrix.org") + XCTAssertEqual(h1AttributedString.runs.first?.link?.host, "matrix.org") + XCTAssertEqual(h2AttributedString.runs.first?.link?.host, "matrix.org") + XCTAssertEqual(h3AttributedString.runs.first?.link?.host, "matrix.org") } func testRenderHTMLStringWithIFrame() {