mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 04:18:31 +00:00
phishing alert implementation
we now check when building the string through the `AttributedStringBuilder` if a URL is actually hiding a different link, if so, we create a custom URL that contains both the external and the internal URL to advise the user through an Alert about the risk
This commit is contained in:
@@ -746,6 +746,7 @@
|
|||||||
9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; };
|
9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; };
|
||||||
92720AB0DA9AB5EEF1DAF56B /* SecureBackupLogoutConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */; };
|
92720AB0DA9AB5EEF1DAF56B /* SecureBackupLogoutConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */; };
|
||||||
9278EC51D24E57445B290521 /* AudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.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 */; };
|
92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */; };
|
||||||
934051B17A884AB0635DF81B /* BlockedUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */; };
|
934051B17A884AB0635DF81B /* BlockedUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */; };
|
||||||
937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85666E40F7E817809B4FD787 /* ComposerToolbar.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 */; };
|
D6DE764B17FB4A9A12C33BF4 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */; };
|
||||||
D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; };
|
D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; };
|
||||||
D8459AAD6969B1431ECBE990 /* UnsupportedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E535B3388755B65C34CD10 /* UnsupportedRoomTimelineView.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 */; };
|
D8CFA0EE46376F9FF04EEE45 /* TextRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4853C923A1AF43711D025EAF /* TextRoomTimelineView.swift */; };
|
||||||
D8F1462EA00AFC939FF9ACCA /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 203D1ACC20287F8986C959D3 /* target.yml */; };
|
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 */; };
|
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 = "<group>"; };
|
1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DTHTMLElement+AttributedStringBuilder.swift"; sourceTree = "<group>"; };
|
||||||
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = "<group>"; };
|
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = "<group>"; };
|
||||||
1F7C6DDBB5D12F6EF6A3D6E1 /* CollapsibleReactionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleReactionLayout.swift; sourceTree = "<group>"; };
|
1F7C6DDBB5D12F6EF6A3D6E1 /* CollapsibleReactionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleReactionLayout.swift; sourceTree = "<group>"; };
|
||||||
|
1F8C01DEEA83903D45069BBD /* CharacterSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterSet.swift; sourceTree = "<group>"; };
|
||||||
1FAF8C2226A57B9AB7446B31 /* AppLockSetupPINScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenCoordinator.swift; sourceTree = "<group>"; };
|
1FAF8C2226A57B9AB7446B31 /* AppLockSetupPINScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||||
1FD51B4D5173F7FC886F5360 /* NoticeRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItemContent.swift; sourceTree = "<group>"; };
|
1FD51B4D5173F7FC886F5360 /* NoticeRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItemContent.swift; sourceTree = "<group>"; };
|
||||||
1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMediaEventsTimelineView.swift; sourceTree = "<group>"; };
|
1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMediaEventsTimelineView.swift; sourceTree = "<group>"; };
|
||||||
@@ -3589,6 +3592,7 @@
|
|||||||
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */,
|
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */,
|
||||||
3339B1DDB1341E833D2555BC /* AVMetadataMachineReadableCodeObject.swift */,
|
3339B1DDB1341E833D2555BC /* AVMetadataMachineReadableCodeObject.swift */,
|
||||||
B6E89E530A8E92EC44301CA1 /* Bundle.swift */,
|
B6E89E530A8E92EC44301CA1 /* Bundle.swift */,
|
||||||
|
1F8C01DEEA83903D45069BBD /* CharacterSet.swift */,
|
||||||
9A1C33355FFB0F0953C35036 /* ClientBuilder.swift */,
|
9A1C33355FFB0F0953C35036 /* ClientBuilder.swift */,
|
||||||
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */,
|
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */,
|
||||||
E2B1CC9AA154F4D5435BF60A /* Comparable.swift */,
|
E2B1CC9AA154F4D5435BF60A /* Comparable.swift */,
|
||||||
@@ -6595,6 +6599,7 @@
|
|||||||
BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */,
|
BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */,
|
||||||
F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */,
|
F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */,
|
||||||
9A3B0CDF097E3838FB1B9595 /* Bundle.swift in Sources */,
|
9A3B0CDF097E3838FB1B9595 /* Bundle.swift in Sources */,
|
||||||
|
9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */,
|
||||||
238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */,
|
238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */,
|
||||||
0BAF83521871E69D222EE8E4 /* ClientBuilderHook.swift in Sources */,
|
0BAF83521871E69D222EE8E4 /* ClientBuilderHook.swift in Sources */,
|
||||||
211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */,
|
211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */,
|
||||||
@@ -6963,6 +6968,7 @@
|
|||||||
BB6BF528BC7F5B87E08C4F18 /* CameraPicker.swift in Sources */,
|
BB6BF528BC7F5B87E08C4F18 /* CameraPicker.swift in Sources */,
|
||||||
E14E469CD97550D0FC58F3CA /* CancellableTask.swift in Sources */,
|
E14E469CD97550D0FC58F3CA /* CancellableTask.swift in Sources */,
|
||||||
DF8F1211F2B0B56F0FCCA5C2 /* CertificateValidatorHook.swift in Sources */,
|
DF8F1211F2B0B56F0FCCA5C2 /* CertificateValidatorHook.swift in Sources */,
|
||||||
|
D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */,
|
||||||
A52090A4FE0DB826578DFC03 /* Client.swift in Sources */,
|
A52090A4FE0DB826578DFC03 /* Client.swift in Sources */,
|
||||||
C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */,
|
C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */,
|
||||||
87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */,
|
87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
struct Application: App {
|
struct Application: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
|
@State private var alert: AlertInfo<ApplicationAlertType>?
|
||||||
|
|
||||||
private var appCoordinator: AppCoordinatorProtocol!
|
private var appCoordinator: AppCoordinatorProtocol!
|
||||||
|
|
||||||
@@ -34,13 +35,21 @@ struct Application: App {
|
|||||||
if appCoordinator.handleDeepLink(url, isExternalURL: false) {
|
if appCoordinator.handleDeepLink(url, isExternalURL: false) {
|
||||||
return .handled
|
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
|
return .systemAction
|
||||||
})
|
})
|
||||||
.onOpenURL {
|
.onOpenURL { url in
|
||||||
if !appCoordinator.handleDeepLink($0, isExternalURL: true) {
|
openURL(url)
|
||||||
openURLInSystemBrowser($0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onContinueUserActivity("INStartVideoCallIntent") { userActivity in
|
.onContinueUserActivity("INStartVideoCallIntent") { userActivity in
|
||||||
// `INStartVideoCallIntent` is to be replaced with `INStartCallIntent`
|
// `INStartVideoCallIntent` is to be replaced with `INStartCallIntent`
|
||||||
@@ -50,10 +59,17 @@ struct Application: App {
|
|||||||
.task {
|
.task {
|
||||||
appCoordinator.start()
|
appCoordinator.start()
|
||||||
}
|
}
|
||||||
|
.alert(item: $alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// 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
|
/// Hide the status bar so it doesn't interfere with the screenshot tests
|
||||||
private var shouldHideStatusBar: Bool {
|
private var shouldHideStatusBar: Bool {
|
||||||
@@ -81,3 +97,7 @@ struct Application: App {
|
|||||||
openURL(url)
|
openURL(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ApplicationAlertType {
|
||||||
|
case confirmURL
|
||||||
|
}
|
||||||
|
|||||||
@@ -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._=-/@:")
|
||||||
|
}
|
||||||
@@ -101,6 +101,20 @@ extension URL: @retroactive ExpressibleByStringLiteral {
|
|||||||
return nil
|
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
|
// MARK: Mocks
|
||||||
|
|
||||||
static var mockMXCAudio: URL { "mxc://matrix.org/1234567890AuDiO" }
|
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 mockMXCAvatar: URL { "mxc://matrix.org/1234567890AvAtAr" }
|
||||||
static var mockMXCUserAvatar: URL { "mxc://matrix.org/1234567890AvAtArUsEr" }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
|
|
||||||
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
|
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
|
||||||
removeDefaultForegroundColors(mutableAttributedString)
|
removeDefaultForegroundColors(mutableAttributedString)
|
||||||
|
detectPhishingAttempts(mutableAttributedString)
|
||||||
addLinksAndMentions(mutableAttributedString)
|
addLinksAndMentions(mutableAttributedString)
|
||||||
replaceMarkedBlockquotes(mutableAttributedString)
|
replaceMarkedBlockquotes(mutableAttributedString)
|
||||||
replaceMarkedCodeBlocks(mutableAttributedString)
|
replaceMarkedCodeBlocks(mutableAttributedString)
|
||||||
@@ -177,19 +178,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var link = String(string[matchRange])
|
let link = sanitizeLink(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())
|
|
||||||
}
|
|
||||||
|
|
||||||
return TextParsingMatch(type: .link(urlString: link), range: match.range)
|
return TextParsingMatch(type: .link(urlString: link), range: match.range)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -278,7 +267,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
func detectPermalinks(_ attributedString: NSMutableAttributedString) {
|
func detectPermalinks(_ attributedString: NSMutableAttributedString) {
|
||||||
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
|
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
|
||||||
if value != nil {
|
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 {
|
switch matrixEntity.id {
|
||||||
case .user(let userID):
|
case .user(let userID):
|
||||||
mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil)
|
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) {
|
private func removeDTCoreTextArtifacts(_ attributedString: NSMutableAttributedString) {
|
||||||
guard attributedString.length > 0 else {
|
guard attributedString.length > 0 else {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -137,11 +137,14 @@ struct MessageText: UIViewRepresentable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
@available(iOS 17.0, *)
|
||||||
func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? {
|
func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? {
|
||||||
switch textItem.content {
|
switch textItem.content {
|
||||||
case let .link(url):
|
case let .link(url):
|
||||||
|
guard !url.requiresConfirmation else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// We don't want to show a URL preview for permalinks
|
// We don't want to show a URL preview for permalinks
|
||||||
let isPermalink = parseMatrixEntityFrom(uri: url.absoluteString) != nil
|
let isPermalink = parseMatrixEntityFrom(uri: url.absoluteString) != nil
|
||||||
return .init(preview: isPermalink ? nil : .default, menu: defaultMenu)
|
return .init(preview: isPermalink ? nil : .default, menu: defaultMenu)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ targets:
|
|||||||
- path: ../../ElementX/Sources/Other/Extensions/UNNotificationContent.swift
|
- path: ../../ElementX/Sources/Other/Extensions/UNNotificationContent.swift
|
||||||
- path: ../../ElementX/Sources/Other/Extensions/URL.swift
|
- path: ../../ElementX/Sources/Other/Extensions/URL.swift
|
||||||
- path: ../../ElementX/Sources/Other/Extensions/UTType.swift
|
- path: ../../ElementX/Sources/Other/Extensions/UTType.swift
|
||||||
|
- path: ../../ElementX/Sources/Other/Extensions/CharacterSet.swift
|
||||||
- path: ../../ElementX/Sources/Other/HTMLParsing
|
- path: ../../ElementX/Sources/Other/HTMLParsing
|
||||||
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
|
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
|
||||||
- path: ../../ElementX/Sources/Other/Logging
|
- path: ../../ElementX/Sources/Other/Logging
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRenderHTMLStringWithLinkInHeader() {
|
func testRenderHTMLStringWithLinkInHeader() {
|
||||||
let h1HTMLString = "<h1><a href=\"https://www.matrix.org/\">Matrix.org</a></h1>"
|
let h1HTMLString = "<h1><a href=\"https://matrix.org/\">Matrix.org</a></h1>"
|
||||||
let h2HTMLString = "<h2><a href=\"https://www.matrix.org/\">Matrix.org</a></h2>"
|
let h2HTMLString = "<h2><a href=\"https://matrix.org/\">Matrix.org</a></h2>"
|
||||||
let h3HTMLString = "<h3><a href=\"https://www.matrix.org/\">Matrix.org</a></h3>"
|
let h3HTMLString = "<h3><a href=\"https://matrix.org/\">Matrix.org</a></h3>"
|
||||||
|
|
||||||
guard let h1AttributedString = attributedStringBuilder.fromHTML(h1HTMLString),
|
guard let h1AttributedString = attributedStringBuilder.fromHTML(h1HTMLString),
|
||||||
let h2AttributedString = attributedStringBuilder.fromHTML(h2HTMLString),
|
let h2AttributedString = attributedStringBuilder.fromHTML(h2HTMLString),
|
||||||
@@ -168,9 +168,9 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
XCTAssert(h1Font.pointSize > UIFont.preferredFont(forTextStyle: .body).pointSize)
|
XCTAssert(h1Font.pointSize > UIFont.preferredFont(forTextStyle: .body).pointSize)
|
||||||
XCTAssert(h1Font.pointSize <= maxHeaderPointSize)
|
XCTAssert(h1Font.pointSize <= maxHeaderPointSize)
|
||||||
|
|
||||||
XCTAssertEqual(h1AttributedString.runs.first?.link?.host, "www.matrix.org")
|
XCTAssertEqual(h1AttributedString.runs.first?.link?.host, "matrix.org")
|
||||||
XCTAssertEqual(h2AttributedString.runs.first?.link?.host, "www.matrix.org")
|
XCTAssertEqual(h2AttributedString.runs.first?.link?.host, "matrix.org")
|
||||||
XCTAssertEqual(h3AttributedString.runs.first?.link?.host, "www.matrix.org")
|
XCTAssertEqual(h3AttributedString.runs.first?.link?.host, "matrix.org")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRenderHTMLStringWithIFrame() {
|
func testRenderHTMLStringWithIFrame() {
|
||||||
|
|||||||
Reference in New Issue
Block a user