added tests

improvement

improved

improvement

fixed
This commit is contained in:
Mauro Romito
2025-03-13 20:48:24 +01:00
committed by Mauro
parent 516892ed7b
commit aa35bfc245
12 changed files with 504 additions and 104 deletions
@@ -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
}
}