mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
added tests
improvement improved improvement fixed
This commit is contained in:
@@ -195,6 +195,19 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
||||
)
|
||||
}
|
||||
|
||||
func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool {
|
||||
guard let confirmationParameters = url.confirmationParameters else {
|
||||
return false
|
||||
}
|
||||
ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(),
|
||||
title: L10n.dialogConfirmLinkTitle,
|
||||
message: L10n.dialogConfirmLinkMessage(confirmationParameters.displayString,
|
||||
confirmationParameters.internalURL.absoluteString),
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionContinue) { openURLAction(confirmationParameters.internalURL) })
|
||||
return true
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool {
|
||||
// Parse into an AppRoute to redirect these in a type safe way.
|
||||
|
||||
|
||||
@@ -13,5 +13,7 @@ protocol AppCoordinatorProtocol: CoordinatorProtocol {
|
||||
|
||||
@discardableResult func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool
|
||||
|
||||
func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool
|
||||
|
||||
func handleUserActivity(_ userActivity: NSUserActivity)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import SwiftUI
|
||||
struct Application: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var alert: AlertInfo<ApplicationAlertType>?
|
||||
|
||||
private var appCoordinator: AppCoordinatorProtocol!
|
||||
|
||||
@@ -36,20 +35,16 @@ struct Application: App {
|
||||
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))
|
||||
|
||||
if appCoordinator.handlePotentialPhishingAttempt(url: url, openURLAction: { url in
|
||||
openURL(url, isExternalURL: false)
|
||||
}) {
|
||||
return .handled
|
||||
}
|
||||
|
||||
return .systemAction
|
||||
})
|
||||
.onOpenURL { url in
|
||||
openURL(url)
|
||||
openURL(url, isExternalURL: true)
|
||||
}
|
||||
.onContinueUserActivity("INStartVideoCallIntent") { userActivity in
|
||||
// `INStartVideoCallIntent` is to be replaced with `INStartCallIntent`
|
||||
@@ -59,14 +54,13 @@ struct Application: App {
|
||||
.task {
|
||||
appCoordinator.start()
|
||||
}
|
||||
.alert(item: $alert)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func openURL(_ url: URL) {
|
||||
if !appCoordinator.handleDeepLink(url, isExternalURL: true) {
|
||||
private func openURL(_ url: URL, isExternalURL: Bool) {
|
||||
if !appCoordinator.handleDeepLink(url, isExternalURL: isExternalURL) {
|
||||
openURLInSystemBrowser(url)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +91,3 @@ struct Application: App {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
enum ApplicationAlertType {
|
||||
case confirmURL
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ extension CharacterSet {
|
||||
}()
|
||||
|
||||
static let matrixUserIDAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789._=-/@:")
|
||||
static let roomAliasAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!$&‘()*+/;=?@[]-._:#")
|
||||
}
|
||||
|
||||
@@ -118,3 +118,22 @@ extension String {
|
||||
return UTType(filenameExtension: fileExtension) != nil ? fileExtension : "bin"
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// To be used if the string is actually a URL
|
||||
var asSanitizedLink: String {
|
||||
var link = self
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,23 +126,26 @@ extension URL: @retroactive ExpressibleByStringLiteral {
|
||||
}
|
||||
|
||||
struct ConfirmURLParameters {
|
||||
static let internalURLKey = "internalURL"
|
||||
static let displayStringKey = "displayString"
|
||||
|
||||
let internalURL: URL
|
||||
let linkString: String
|
||||
let displayString: String
|
||||
|
||||
var urlQueryItems: [URLQueryItem] {
|
||||
[URLQueryItem(name: "internalURL", value: internalURL.absoluteString),
|
||||
URLQueryItem(name: "linkString", value: linkString)]
|
||||
[URLQueryItem(name: Self.internalURLKey, value: internalURL.absoluteString),
|
||||
URLQueryItem(name: Self.displayStringKey, value: displayString)]
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfirmURLParameters {
|
||||
init?(queryItems: [URLQueryItem]) {
|
||||
guard let internalURLString = queryItems.first(where: { $0.name == "internalURL" })?.value,
|
||||
guard let internalURLString = queryItems.first(where: { $0.name == Self.internalURLKey })?.value,
|
||||
let internalURL = URL(string: internalURLString),
|
||||
let externalURLString = queryItems.first(where: { $0.name == "linkString" })?.value else {
|
||||
let externalURLString = queryItems.first(where: { $0.name == Self.displayStringKey })?.value else {
|
||||
return nil
|
||||
}
|
||||
linkString = externalURLString
|
||||
displayString = externalURLString
|
||||
self.internalURL = internalURL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
return nil
|
||||
}
|
||||
|
||||
let link = sanitizeLink(String(string[matchRange]))
|
||||
let link = String(string[matchRange]).asSanitizedLink
|
||||
return TextParsingMatch(type: .link(urlString: link), range: match.range)
|
||||
})
|
||||
|
||||
@@ -292,85 +292,32 @@ 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: ".")
|
||||
let displayString = attributedString.attributedSubstring(from: range).string
|
||||
|
||||
// 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.
|
||||
guard PhishingDetector.isPhishingAttempt(displayString: displayString, internalURL: internalURL) else {
|
||||
return
|
||||
}
|
||||
handlePhishingAttempt(for: attributedString, in: range, internalURL: internalURL, displayString: displayString)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
displayString: 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)
|
||||
let parameters = ConfirmURLParameters(internalURL: internalURL, displayString: displayString)
|
||||
urlComponents.queryItems = parameters.urlQueryItems
|
||||
|
||||
guard let finalURL = urlComponents.url else {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// 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
|
||||
import MatrixRustSDK
|
||||
|
||||
enum PhishingDetector {
|
||||
static func isPhishingAttempt(displayString: String, internalURL: URL) -> Bool {
|
||||
// Some phishing attempts can be hidden by using the unicode character "﹒" instead of "."
|
||||
let disambiguatedDisplayString = displayString.replacingOccurrences(of: "﹒", with: ".")
|
||||
let linkMatch = MatrixEntityRegex.linkRegex.firstMatch(in: disambiguatedDisplayString)
|
||||
let linkMatchLength = linkMatch?.range.length ?? 0
|
||||
|
||||
// We check if we the link string contains a matrix user ID.
|
||||
if let match = MatrixEntityRegex.userIdentifierRegex.firstMatch(in: disambiguatedDisplayString),
|
||||
// If there is a bigger permalink including it we leave it handled by the link branch
|
||||
linkMatchLength <= match.range.length,
|
||||
let matchRange = Range(match.range, in: disambiguatedDisplayString) {
|
||||
let identifier = String(disambiguatedDisplayString[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
|
||||
// Like by using whitespaces emojis or other invalid symbols e.g click here [👉️ @alice:matrix.org](https://matrix.org)
|
||||
let trimmedDisplayString = disambiguatedDisplayString.lowercased().trimmingCharacters(in: .matrixUserIDAllowedCharacters.inverted)
|
||||
if identifier == trimmedDisplayString,
|
||||
isMatrixUserIDPhishingAttempt(internalURL: internalURL, identifier: identifier) {
|
||||
return true
|
||||
}
|
||||
// We check if we the link string contains a room alias.
|
||||
} else if let match = MatrixEntityRegex.roomAliasRegex.firstMatch(in: disambiguatedDisplayString),
|
||||
// If there is a bigger permalink including it we leave it handled by the link branch
|
||||
linkMatchLength <= match.range.length,
|
||||
let matchRange = Range(match.range, in: disambiguatedDisplayString) {
|
||||
let alias = String(disambiguatedDisplayString[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
|
||||
// Like by using whitespaces emojis or other invalid symbols e.g click here [👉️ #room:matrix.org](https://matrix.org)
|
||||
let trimmedDisplayString = disambiguatedDisplayString.lowercased().trimmingCharacters(in: .roomAliasAllowedCharacters.inverted)
|
||||
if alias == trimmedDisplayString,
|
||||
isRoomAliasPhishingAttempt(internalURL: internalURL, alias: alias) {
|
||||
return true
|
||||
}
|
||||
// Else we check if the link string is itself what is considered a tappable link for the OS
|
||||
} else if linkMatch != 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
|
||||
// And trim invalid characters that might hide phishing attemps
|
||||
// Like emoji whitespaces and other invalid symbols e.g click here [👉️ https://element.io](https://matrix.org)
|
||||
let trimmedDisplayString = disambiguatedDisplayString.asSanitizedLink.lowercased().trimmingCharacters(in: .urlAllowedCharacters.inverted)
|
||||
if trimmedDisplayString != internalURL.absoluteString.asSanitizedLink.lowercased().removingPercentEncoding {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static 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 static func isRoomAliasPhishingAttempt(internalURL: URL, alias: 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 .roomAlias(let internalAlias):
|
||||
// If it is, and it does not match the external one, it's a phishing attempt
|
||||
return alias != internalAlias
|
||||
default:
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ class UITestsAppCoordinator: AppCoordinatorProtocol, SecureWindowManagerDelegate
|
||||
navigationRootCoordinator.toPresentable()
|
||||
}
|
||||
|
||||
func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool {
|
||||
fatalError("Not implemented.")
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool {
|
||||
fatalError("Not implemented.")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol {
|
||||
AnyView(ProgressView("Running Unit Tests"))
|
||||
}
|
||||
|
||||
func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool {
|
||||
fatalError("Not implemented.")
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool {
|
||||
fatalError("Not implemented.")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user