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:
Mauro Romito
2025-03-12 17:48:21 +01:00
committed by Mauro
parent f248bdfd92
commit 516892ed7b
8 changed files with 199 additions and 25 deletions
+24 -4
View File
@@ -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<ApplicationAlertType>?
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
}
@@ -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
}
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
}
}
@@ -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
@@ -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)